feat(tasks): migrate task statuses to db table with org scope and dynamic board columns
This commit is contained in:
parent
df368218fd
commit
a865dea719
56 changed files with 2739 additions and 349 deletions
|
|
@ -77,13 +77,13 @@ public class Task {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(TaskStatus status) {
|
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()) {
|
if (isRunning()) {
|
||||||
this.stop();
|
this.stop();
|
||||||
}
|
}
|
||||||
if (status == TaskStatus.COMPLETED) {
|
if (status.isCompleted()) {
|
||||||
this.finishedAt = LocalDateTime.now();
|
this.finishedAt = LocalDateTime.now();
|
||||||
} else {
|
} else {
|
||||||
this.finishedAt = null;
|
this.finishedAt = null;
|
||||||
|
|
@ -157,7 +157,6 @@ public class Task {
|
||||||
throw new IllegalStateException("Tarefa já está em execução");
|
throw new IllegalStateException("Tarefa já está em execução");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.status = TaskStatus.IN_PROGRESS;
|
|
||||||
TaskHistoryItem item = new TaskHistoryItem();
|
TaskHistoryItem item = new TaskHistoryItem();
|
||||||
item.setStartAt(LocalDateTime.now());
|
item.setStartAt(LocalDateTime.now());
|
||||||
item.setUser(worker != null ? worker : this.user);
|
item.setUser(worker != null ? worker : this.user);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,106 @@
|
||||||
package net.beetechgroup.beetask.entities.task;
|
package net.beetechgroup.beetask.entities.task;
|
||||||
|
|
||||||
public enum TaskStatus {
|
import java.util.Objects;
|
||||||
NOT_STARTED("NOT_STARTED"),
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
IN_PROGRESS("IN_PROGRESS"),
|
|
||||||
COMPLETED("COMPLETED"),
|
|
||||||
CANCELED("CANCELED");
|
|
||||||
|
|
||||||
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) {
|
public TaskStatus() {
|
||||||
this.status = status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public TaskStatus(Long id, String name, String color, Integer position, boolean isDefault, boolean isCompleted, boolean isCanceled, Organization organization) {
|
||||||
return status;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
|
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
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.UserOrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
|
||||||
|
|
@ -33,8 +34,9 @@ public class OrganizationUseCaseConfig {
|
||||||
@Produces
|
@Produces
|
||||||
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
|
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository);
|
TaskStatusRepository taskStatusRepository) {
|
||||||
|
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository, taskStatusRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.CategoryRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
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.UserOrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||||
|
|
@ -28,13 +29,15 @@ public class TaskUseCaseConfig {
|
||||||
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository,
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||||
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
TaskStatusRepository taskStatusRepository) {
|
||||||
|
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository,
|
||||||
return new StartTaskUseCase(taskRepository, userRepository, authorizer);
|
AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) {
|
||||||
|
return new StartTaskUseCase(taskRepository, userRepository, authorizer, taskStatusRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
@ -53,16 +56,19 @@ public class TaskUseCaseConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository,
|
||||||
return new UpdateTaskStatusUseCase(taskRepository, authorizer);
|
TaskStatusRepository taskStatusRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
|
return new UpdateTaskStatusUseCase(taskRepository, taskStatusRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository,
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||||
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
TaskStatusRepository taskStatusRepository) {
|
||||||
|
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import org.hibernate.annotations.SQLDelete;
|
import org.hibernate.annotations.SQLDelete;
|
||||||
import org.hibernate.annotations.SQLRestriction;
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import jakarta.persistence.FetchType;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tasks")
|
@Table(name = "tasks")
|
||||||
|
|
@ -28,8 +28,11 @@ public class TaskEntity {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
@Enumerated(EnumType.STRING)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
private TaskStatus status;
|
@JoinColumn(name = "status_id")
|
||||||
|
private TaskStatusEntity taskStatus;
|
||||||
|
@Column(name = "status", length = 120)
|
||||||
|
private String status;
|
||||||
@Column(name = "finished_at")
|
@Column(name = "finished_at")
|
||||||
private java.time.LocalDateTime finishedAt;
|
private java.time.LocalDateTime finishedAt;
|
||||||
@Column(name = "external_url", length = 1000)
|
@Column(name = "external_url", length = 1000)
|
||||||
|
|
@ -103,11 +106,19 @@ public class TaskEntity {
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TaskStatus getStatus() {
|
public TaskStatusEntity getTaskStatus() {
|
||||||
|
return taskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskStatus(TaskStatusEntity taskStatus) {
|
||||||
|
this.taskStatus = taskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(TaskStatus status) {
|
public void setStatus(String status) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
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.TaskEntity;
|
||||||
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity;
|
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity;
|
||||||
|
|
||||||
|
|
@ -14,7 +15,8 @@ public class TaskEntityMapper {
|
||||||
entity.setId(task.getId());
|
entity.setId(task.getId());
|
||||||
entity.setTitle(task.getTitle());
|
entity.setTitle(task.getTitle());
|
||||||
entity.setDescription(task.getDescription());
|
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.setFinishedAt(task.getFinishedAt());
|
||||||
entity.setExternalUrl(task.getExternalUrl());
|
entity.setExternalUrl(task.getExternalUrl());
|
||||||
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
|
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
|
||||||
|
|
@ -37,7 +39,13 @@ public class TaskEntityMapper {
|
||||||
task.setId(entity.getId());
|
task.setId(entity.getId());
|
||||||
task.setTitle(entity.getTitle());
|
task.setTitle(entity.getTitle());
|
||||||
task.setDescription(entity.getDescription());
|
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.setFinishedAt(entity.getFinishedAt());
|
||||||
task.setExternalUrl(entity.getExternalUrl());
|
task.setExternalUrl(entity.getExternalUrl());
|
||||||
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
|
List<String> statuses, List<Long> categoryIds, List<Long> userIds) {
|
||||||
StringBuilder query = new StringBuilder(
|
StringBuilder query = new StringBuilder(
|
||||||
"select distinct t from TaskEntity t left join t.project p left join t.category c " +
|
"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)"
|
"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<Tas
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
||||||
query.append(" and t.status in ?").append(idx);
|
query.append(" and (t.status in ?").append(idx).append(" or lower(t.taskStatus.name) in ?").append(idx).append(")");
|
||||||
params.add(statuses);
|
params.add(statuses);
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds) {
|
List<String> statuses, List<Long> categoryIds) {
|
||||||
StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))");
|
StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))");
|
||||||
List<Object> params = new ArrayList<>();
|
List<Object> params = new ArrayList<>();
|
||||||
params.add(email);
|
params.add(email);
|
||||||
|
|
@ -118,7 +118,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
||||||
query.append(" and status in ?").append(idx);
|
query.append(" and (status in ?").append(idx).append(" or lower(taskStatus.name) in ?").append(idx).append(")");
|
||||||
params.add(statuses);
|
params.add(statuses);
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
@ -167,8 +167,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("user.email = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
return find("user.email = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||||
email, TaskStatus.COMPLETED, start, end)
|
email, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,8 +193,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
return find("select distinct t from TaskEntity t " +
|
return find("select distinct t from TaskEntity t " +
|
||||||
"left join t.project p left join t.category c " +
|
"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) " +
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " +
|
"and t.finishedAt is not null and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||||
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
|
||||||
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,8 +224,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
return find("user.id = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||||
userId, TaskStatus.COMPLETED, start, end)
|
userId, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,8 +234,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
return find("select distinct t from TaskEntity t " +
|
return find("select distinct t from TaskEntity t " +
|
||||||
"left join t.project p left join t.category c " +
|
"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) " +
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
"and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
"and t.user.id = ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
|
||||||
orgId, userId, TaskStatus.COMPLETED, start, end)
|
orgId, userId, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,8 +278,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
|
LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
return find("user.email in ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
return find("user.email in ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||||
emails, TaskStatus.COMPLETED, start, end)
|
emails, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,14 +292,14 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
return find("select distinct t from TaskEntity t " +
|
return find("select distinct t from TaskEntity t " +
|
||||||
"left join t.project p left join t.category c " +
|
"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) " +
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
"and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
"and t.user.email in ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
|
||||||
orgId, emails, TaskStatus.COMPLETED, start, end)
|
orgId, emails, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countByStatus(TaskStatus status) {
|
public long countCompletedTasks() {
|
||||||
return count("status", status);
|
return count("finishedAt is not null or (taskStatus is not null and taskStatus.isCompleted = true)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -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<TaskStatusEntity, Long> {
|
||||||
|
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<TaskStatus> findTaskStatusById(Long id) {
|
||||||
|
return findByIdOptional(id).map(TaskStatusEntityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TaskStatus> findByOrganizationId(Long organizationId) {
|
||||||
|
return find("organization.id = ?1 order by position asc, id asc", organizationId)
|
||||||
|
.list()
|
||||||
|
.stream()
|
||||||
|
.map(TaskStatusEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TaskStatus> findDefaultByOrganizationId(Long organizationId) {
|
||||||
|
return find("organization.id = ?1 and isDefault = true", organizationId)
|
||||||
|
.firstResultOptional()
|
||||||
|
.map(TaskStatusEntityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TaskStatus> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,11 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public record CreateTaskRequest(
|
public record CreateTaskRequest(
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
String status,
|
||||||
|
Long statusId,
|
||||||
Long organizationId,
|
Long organizationId,
|
||||||
Long projectId,
|
Long projectId,
|
||||||
Long categoryId,
|
Long categoryId,
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public record CreateTaskResponse(
|
public record CreateTaskResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
String status,
|
||||||
|
TaskStatusResponse taskStatus,
|
||||||
ProjectResponse project,
|
ProjectResponse project,
|
||||||
CategoryResponse category,
|
CategoryResponse category,
|
||||||
UserResponse user,
|
UserResponse user,
|
||||||
|
|
@ -18,8 +17,16 @@ public record CreateTaskResponse(
|
||||||
List<TaskHistoryItemResponse> history,
|
List<TaskHistoryItemResponse> history,
|
||||||
String externalUrl) {
|
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 ProjectResponse(Long id, String name) {}
|
||||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||||
public record UserResponse(Long id, String name, String email, String photo) {}
|
public record UserResponse(Long id, String name, String email, String photo) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public class ListMyTasksRequest {
|
public class ListMyTasksRequest {
|
||||||
|
|
||||||
|
|
@ -14,7 +12,7 @@ public class ListMyTasksRequest {
|
||||||
public List<Long> projectIds;
|
public List<Long> projectIds;
|
||||||
|
|
||||||
@QueryParam("status")
|
@QueryParam("status")
|
||||||
public List<TaskStatus> statuses;
|
public List<String> statuses;
|
||||||
|
|
||||||
@QueryParam("categoryId")
|
@QueryParam("categoryId")
|
||||||
public List<Long> categoryIds;
|
public List<Long> categoryIds;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public class ListTasksRequest {
|
public class ListTasksRequest {
|
||||||
|
|
||||||
|
|
@ -17,7 +15,7 @@ public class ListTasksRequest {
|
||||||
public List<Long> projectIds;
|
public List<Long> projectIds;
|
||||||
|
|
||||||
@QueryParam("status")
|
@QueryParam("status")
|
||||||
public List<TaskStatus> statuses;
|
public List<String> statuses;
|
||||||
|
|
||||||
@QueryParam("categoryId")
|
@QueryParam("categoryId")
|
||||||
public List<Long> categoryIds;
|
public List<Long> categoryIds;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
import org.jboss.logging.Logger;
|
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.CreateTaskInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||||
|
|
@ -145,15 +144,41 @@ public class TaskController {
|
||||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
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
|
@PATCH
|
||||||
@Path("/{id}/status")
|
@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")
|
@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();
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email);
|
LOGGER.infof("Status update (PATCH) requested for task %d to %s by %s", id, rawStatus, email);
|
||||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email);
|
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email);
|
||||||
LOGGER.infof("Task %d status updated successfully to %s", id, status);
|
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);
|
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
@ -12,20 +14,42 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
|
||||||
|
|
||||||
public class TaskControllerMapper {
|
public class TaskControllerMapper {
|
||||||
|
|
||||||
|
private static List<String> normalizeStatuses(List<String> rawStatuses) {
|
||||||
|
if (rawStatuses == null || rawStatuses.isEmpty()) {
|
||||||
|
return rawStatuses;
|
||||||
|
}
|
||||||
|
List<String> 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) {
|
public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) {
|
||||||
return new ListTasksInput(
|
return new ListTasksInput(
|
||||||
email,
|
email,
|
||||||
request.organizationId,
|
request.organizationId,
|
||||||
request.text,
|
request.text,
|
||||||
request.projectIds,
|
request.projectIds,
|
||||||
request.statuses,
|
normalizeStatuses(request.statuses),
|
||||||
request.categoryIds,
|
request.categoryIds,
|
||||||
request.userIds
|
request.userIds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
|
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) {
|
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
|
||||||
|
|
@ -33,6 +57,7 @@ public class TaskControllerMapper {
|
||||||
request.title(),
|
request.title(),
|
||||||
request.description(),
|
request.description(),
|
||||||
request.status(),
|
request.status(),
|
||||||
|
request.statusId(),
|
||||||
request.organizationId(),
|
request.organizationId(),
|
||||||
request.projectId(),
|
request.projectId(),
|
||||||
request.categoryId(),
|
request.categoryId(),
|
||||||
|
|
@ -50,7 +75,15 @@ public class TaskControllerMapper {
|
||||||
output.id(),
|
output.id(),
|
||||||
output.title(),
|
output.title(),
|
||||||
output.description(),
|
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())
|
Objects.nonNull(output.project())
|
||||||
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
|
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -96,6 +129,7 @@ public class TaskControllerMapper {
|
||||||
request.title(),
|
request.title(),
|
||||||
request.description(),
|
request.description(),
|
||||||
request.status(),
|
request.status(),
|
||||||
|
request.statusId(),
|
||||||
request.organizationId(),
|
request.organizationId(),
|
||||||
request.projectId(),
|
request.projectId(),
|
||||||
request.categoryId(),
|
request.categoryId(),
|
||||||
|
|
|
||||||
|
|
@ -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<TaskStatusResponse> 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<TaskStatusResponse> reorderStatuses(@PathParam("orgId") Long orgId, List<Long> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,9 @@ import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganization;
|
import net.beetechgroup.beetask.entities.organization.UserOrganization;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
|
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
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.OrganizationRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
|
||||||
|
|
@ -17,13 +19,16 @@ public class CreateOrganizationUseCase {
|
||||||
private final OrganizationRepository organizationRepository;
|
private final OrganizationRepository organizationRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserOrganizationRepository userOrganizationRepository;
|
private final UserOrganizationRepository userOrganizationRepository;
|
||||||
|
private final TaskStatusRepository taskStatusRepository;
|
||||||
|
|
||||||
public CreateOrganizationUseCase(OrganizationRepository organizationRepository,
|
public CreateOrganizationUseCase(OrganizationRepository organizationRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
TaskStatusRepository taskStatusRepository) {
|
||||||
this.organizationRepository = organizationRepository;
|
this.organizationRepository = organizationRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userOrganizationRepository = userOrganizationRepository;
|
this.userOrganizationRepository = userOrganizationRepository;
|
||||||
|
this.taskStatusRepository = taskStatusRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateOrganizationOutput execute(CreateOrganizationInput input) {
|
public CreateOrganizationOutput execute(CreateOrganizationInput input) {
|
||||||
|
|
@ -59,6 +64,17 @@ public class CreateOrganizationUseCase {
|
||||||
userOrganizationRepository.save(userOrganization);
|
userOrganizationRepository.save(userOrganization);
|
||||||
LOGGER.infof("Organization %d created successfully with owner user %d", organization.getId(), user.getId());
|
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);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package net.beetechgroup.beetask.usecase.publicstats;
|
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.OrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
|
@ -19,7 +18,7 @@ public class PublicStatsUseCase {
|
||||||
public PublicStatsOutput execute() {
|
public PublicStatsOutput execute() {
|
||||||
long totalUsers = userRepository.countAll();
|
long totalUsers = userRepository.countAll();
|
||||||
long totalOrganizations = organizationRepository.countAll();
|
long totalOrganizations = organizationRepository.countAll();
|
||||||
long totalCompletedTasks = taskRepository.countByStatus(TaskStatus.COMPLETED);
|
long totalCompletedTasks = taskRepository.countCompletedTasks();
|
||||||
double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0;
|
double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0;
|
||||||
|
|
||||||
return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours);
|
return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours);
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,14 @@ public class GenerateReportUseCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -457,13 +464,8 @@ public class GenerateReportUseCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatStatus(TaskStatus status) {
|
private String formatStatus(TaskStatus status) {
|
||||||
if (status == null) return "";
|
if (status == null || status.getName() == null) return "";
|
||||||
return switch (status) {
|
return status.getName();
|
||||||
case COMPLETED -> "Concluído";
|
|
||||||
case IN_PROGRESS -> "Em Andamento";
|
|
||||||
case NOT_STARTED -> "A Fazer";
|
|
||||||
case CANCELED -> "Cancelado";
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String capitalize(String text) {
|
private String capitalize(String text) {
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,14 @@ public interface TaskRepository {
|
||||||
Task findTaskById(Long id);
|
Task findTaskById(Long id);
|
||||||
List<Task> findAllTasks();
|
List<Task> findAllTasks();
|
||||||
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
|
List<String> statuses, List<Long> categoryIds, List<Long> userIds);
|
||||||
List<Task> findRunningTasksByUserEmail(String email);
|
List<Task> findRunningTasksByUserEmail(String email);
|
||||||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
|
||||||
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksByUser(String email);
|
List<Task> findTasksByUser(String email);
|
||||||
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds);
|
List<String> statuses, List<Long> categoryIds);
|
||||||
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
|
|
@ -29,7 +29,7 @@ public interface TaskRepository {
|
||||||
List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
long countByStatus(TaskStatus status);
|
long countCompletedTasks();
|
||||||
long sumAllCompletedTrackedMinutes();
|
long sumAllCompletedTrackedMinutes();
|
||||||
void deleteTask(Long id);
|
void deleteTask(Long id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<TaskStatus> findTaskStatusById(Long id);
|
||||||
|
List<TaskStatus> findByOrganizationId(Long organizationId);
|
||||||
|
Optional<TaskStatus> findDefaultByOrganizationId(Long organizationId);
|
||||||
|
Optional<TaskStatus> 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);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.create;
|
package net.beetechgroup.beetask.usecase.task.create;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
|
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
|
||||||
|
|
||||||
public record CreateTaskInput(
|
public record CreateTaskInput(
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
String status,
|
||||||
|
Long statusId,
|
||||||
Long organizationId,
|
Long organizationId,
|
||||||
Long projectId,
|
Long projectId,
|
||||||
Long categoryId,
|
Long categoryId,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import net.beetechgroup.beetask.entities.User;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
|
||||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
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.ProjectRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
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.repository.UserRepository;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
|
@ -24,17 +26,20 @@ public class CreateTaskUseCase {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserOrganizationRepository userOrganizationRepository;
|
private final UserOrganizationRepository userOrganizationRepository;
|
||||||
private final OrganizationRepository organizationRepository;
|
private final OrganizationRepository organizationRepository;
|
||||||
|
private final TaskStatusRepository taskStatusRepository;
|
||||||
|
|
||||||
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository,
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
OrganizationRepository organizationRepository) {
|
OrganizationRepository organizationRepository,
|
||||||
|
TaskStatusRepository taskStatusRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userOrganizationRepository = userOrganizationRepository;
|
this.userOrganizationRepository = userOrganizationRepository;
|
||||||
this.organizationRepository = organizationRepository;
|
this.organizationRepository = organizationRepository;
|
||||||
|
this.taskStatusRepository = taskStatusRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(CreateTaskInput input) {
|
public CreateTaskOutput execute(CreateTaskInput input) {
|
||||||
|
|
@ -42,7 +47,6 @@ public class CreateTaskUseCase {
|
||||||
Task task = new Task();
|
Task task = new Task();
|
||||||
task.setTitle(input.title());
|
task.setTitle(input.title());
|
||||||
task.setDescription(input.description());
|
task.setDescription(input.description());
|
||||||
task.setStatus(input.status());
|
|
||||||
task.setExternalUrl(input.externalUrl());
|
task.setExternalUrl(input.externalUrl());
|
||||||
|
|
||||||
if (Objects.nonNull(input.projectId())) {
|
if (Objects.nonNull(input.projectId())) {
|
||||||
|
|
@ -72,6 +76,28 @@ public class CreateTaskUseCase {
|
||||||
task.setOrganization(task.getCategory().getOrganization());
|
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<TaskStatus> all = taskStatusRepository.findByOrganizationId(task.getOrganization().getId());
|
||||||
|
return all.isEmpty() ? null : all.get(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
task.setStatus(taskStatus);
|
||||||
|
|
||||||
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
||||||
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
|
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
|
||||||
task.setCreatedBy(creator);
|
task.setCreatedBy(creator);
|
||||||
|
|
@ -87,7 +113,8 @@ public class CreateTaskUseCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,5 @@ package net.beetechgroup.beetask.usecase.task.listall;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public record ListMyTasksInput(String email, String text, List<Long> projectIds,
|
public record ListMyTasksInput(String email, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds) {}
|
List<String> statuses, List<Long> categoryIds) {}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ package net.beetechgroup.beetask.usecase.task.listall;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public record ListTasksInput(
|
public record ListTasksInput(
|
||||||
String userEmail,
|
String userEmail,
|
||||||
Long organizationId,
|
Long organizationId,
|
||||||
String text,
|
String text,
|
||||||
List<Long> projectIds,
|
List<Long> projectIds,
|
||||||
List<TaskStatus> statuses,
|
List<String> statuses,
|
||||||
List<Long> categoryIds,
|
List<Long> categoryIds,
|
||||||
List<Long> userIds) {}
|
List<Long> userIds) {}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package net.beetechgroup.beetask.usecase.task.start;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
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.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
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.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
@ -15,11 +17,14 @@ public class StartTaskUseCase {
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
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.taskRepository = taskRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.authorizer = authorizer;
|
this.authorizer = authorizer;
|
||||||
|
this.taskStatusRepository = taskStatusRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(StartTaskInput input) {
|
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);
|
task.start(currentUser);
|
||||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||||
LOGGER.infof("Task %d started successfully", input.id());
|
LOGGER.infof("Task %d started successfully", input.id());
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.update;
|
package net.beetechgroup.beetask.usecase.task.update;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
|
||||||
|
|
||||||
public record UpdateTaskInput(
|
public record UpdateTaskInput(
|
||||||
Long id,
|
Long id,
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
String status,
|
||||||
|
Long statusId,
|
||||||
Long organizationId,
|
Long organizationId,
|
||||||
Long projectId,
|
Long projectId,
|
||||||
Long categoryId,
|
Long categoryId,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
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.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
@ -12,19 +13,25 @@ public class UpdateTaskStatusUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final TaskStatusRepository taskStatusRepository;
|
||||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
public UpdateTaskStatusUseCase(TaskRepository taskRepository,
|
||||||
|
TaskStatusRepository taskStatusRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.taskStatusRepository = taskStatusRepository;
|
||||||
this.authorizer = authorizer;
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) {
|
public CreateTaskOutput execute(Long id, Long newStatusId, String newStatusName, String userEmail) {
|
||||||
LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail);
|
LOGGER.infof("Updating status for task %d (statusId=%s, statusName=%s) by user %s", id, newStatusId, newStatusName, userEmail);
|
||||||
Task task = taskRepository.findTaskById(id);
|
Task task = taskRepository.findTaskById(id);
|
||||||
|
|
||||||
Long orgId = null;
|
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();
|
orgId = task.getProject().getOrganization().getId();
|
||||||
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
orgId = task.getCategory().getOrganization().getId();
|
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.");
|
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));
|
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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import net.beetechgroup.beetask.entities.User;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
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.CategoryRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
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.UserOrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
|
|
@ -25,17 +27,20 @@ public class UpdateTaskUseCase {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserOrganizationRepository userOrganizationRepository;
|
private final UserOrganizationRepository userOrganizationRepository;
|
||||||
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
|
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
|
||||||
|
private final TaskStatusRepository taskStatusRepository;
|
||||||
|
|
||||||
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository,
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||||
|
TaskStatusRepository taskStatusRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userOrganizationRepository = userOrganizationRepository;
|
this.userOrganizationRepository = userOrganizationRepository;
|
||||||
this.organizationRepository = organizationRepository;
|
this.organizationRepository = organizationRepository;
|
||||||
|
this.taskStatusRepository = taskStatusRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(UpdateTaskInput input) {
|
public CreateTaskOutput execute(UpdateTaskInput input) {
|
||||||
|
|
@ -58,9 +63,28 @@ public class UpdateTaskUseCase {
|
||||||
|
|
||||||
task.setTitle(input.title());
|
task.setTitle(input.title());
|
||||||
task.setDescription(input.description());
|
task.setDescription(input.description());
|
||||||
task.setStatus(input.status());
|
|
||||||
task.setExternalUrl(input.externalUrl());
|
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())) {
|
if (Objects.nonNull(input.organizationId())) {
|
||||||
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
|
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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<TaskStatus> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<TaskStatus> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<TaskStatusOutput> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<TaskStatusOutput> execute(String userEmail, Long organizationId, List<Long> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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<TaskStatus> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -12,6 +12,7 @@ import { OrganizationsPage } from './pages/OrganizationsPage'
|
||||||
import { TasksPage } from './pages/TasksPage'
|
import { TasksPage } from './pages/TasksPage'
|
||||||
import { ProjectsPage } from './pages/ProjectsPage'
|
import { ProjectsPage } from './pages/ProjectsPage'
|
||||||
import { CategoriesPage } from './pages/CategoriesPage'
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
|
import { StatusesPage } from './pages/StatusesPage'
|
||||||
import { MembersPage } from './pages/MembersPage'
|
import { MembersPage } from './pages/MembersPage'
|
||||||
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
||||||
import { AnalyticsPage } from './pages/AnalyticsPage'
|
import { AnalyticsPage } from './pages/AnalyticsPage'
|
||||||
|
|
@ -45,6 +46,7 @@ export function App() {
|
||||||
<Route path="/kanban" element={<TasksPage />} />
|
<Route path="/kanban" element={<TasksPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route path="/statuses" element={<StatusesPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/members" element={<MembersPage />} />
|
<Route path="/members" element={<MembersPage />} />
|
||||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
FileText,
|
FileText,
|
||||||
Lock,
|
Lock,
|
||||||
|
CircleDot,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Chip, ChipLabel } from '@heroui/react'
|
import { Chip, ChipLabel } from '@heroui/react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
@ -95,6 +96,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
links: [
|
links: [
|
||||||
{ to: '/projects', label: 'Projetos', icon: <FolderKanban className="w-4 h-4" /> },
|
{ to: '/projects', label: 'Projetos', icon: <FolderKanban className="w-4 h-4" /> },
|
||||||
{ to: '/categories', label: 'Categorias', icon: <Tags className="w-4 h-4" /> },
|
{ to: '/categories', label: 'Categorias', icon: <Tags className="w-4 h-4" /> },
|
||||||
|
{ to: '/statuses', label: 'Status', icon: <CircleDot className="w-4 h-4" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { projectService } from '../../services/projectService'
|
||||||
import { categoryService } from '../../services/categoryService'
|
import { categoryService } from '../../services/categoryService'
|
||||||
import { organizationService } from '../../services/organizationService'
|
import { organizationService } from '../../services/organizationService'
|
||||||
import { taskService } from '../../services/taskService'
|
import { taskService } from '../../services/taskService'
|
||||||
|
import { taskStatusService } from '../../services/taskStatusService'
|
||||||
import { CustomSelect } from '../ui/Select'
|
import { CustomSelect } from '../ui/Select'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { canDeleteTask } from '../../lib/taskPermissions'
|
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||||
|
|
@ -42,7 +43,8 @@ interface CreateTaskModalProps {
|
||||||
onSubmit: (taskData: {
|
onSubmit: (taskData: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
status: TaskStatus
|
status: string
|
||||||
|
statusId?: number
|
||||||
projectId?: number
|
projectId?: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
assigneeUserId?: number
|
assigneeUserId?: number
|
||||||
|
|
@ -51,10 +53,11 @@ interface CreateTaskModalProps {
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
onDelete?: (task: Task) => void
|
onDelete?: (task: Task) => void
|
||||||
organizationId: number
|
organizationId: number
|
||||||
initialStatus?: TaskStatus
|
initialStatus?: string
|
||||||
initialTask?: Task | null
|
initialTask?: Task | null
|
||||||
initialTab?: 'details' | 'history'
|
initialTab?: 'details' | 'history'
|
||||||
initialDraft?: Partial<ParsedAiTask> | null
|
initialDraft?: Partial<ParsedAiTask> | null
|
||||||
|
taskStatuses?: TaskStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
|
|
@ -63,10 +66,11 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onDelete,
|
onDelete,
|
||||||
organizationId,
|
organizationId,
|
||||||
initialStatus = 'NOT_STARTED',
|
initialStatus = 'A Fazer',
|
||||||
initialTask = null,
|
initialTask = null,
|
||||||
initialTab = 'details',
|
initialTab = 'details',
|
||||||
initialDraft = null,
|
initialDraft = null,
|
||||||
|
taskStatuses: propTaskStatuses,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, activeOrg } = useAuth()
|
const { user, activeOrg } = useAuth()
|
||||||
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
||||||
|
|
@ -74,7 +78,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
// Task details
|
// Task details
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [status, setStatus] = useState<TaskStatus>(initialStatus)
|
const [status, setStatus] = useState<string>(initialStatus)
|
||||||
const [projectId, setProjectId] = useState<number | undefined>(undefined)
|
const [projectId, setProjectId] = useState<number | undefined>(undefined)
|
||||||
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
|
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
|
||||||
const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined)
|
const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined)
|
||||||
|
|
@ -95,6 +99,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>(propTaskStatuses || [])
|
||||||
const [members, setMembers] = useState<OrganizationMember[]>([])
|
const [members, setMembers] = useState<OrganizationMember[]>([])
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -214,14 +219,18 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
const loadMetadata = async () => {
|
const loadMetadata = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [projRes, catRes, memRes] = await Promise.allSettled([
|
const [projRes, catRes, memRes, statRes] = await Promise.allSettled([
|
||||||
projectService.listProjects(organizationId),
|
projectService.listProjects(organizationId),
|
||||||
categoryService.listCategories(organizationId),
|
categoryService.listCategories(organizationId),
|
||||||
organizationService.listMembers(organizationId),
|
organizationService.listMembers(organizationId),
|
||||||
|
propTaskStatuses && propTaskStatuses.length > 0
|
||||||
|
? Promise.resolve(propTaskStatuses)
|
||||||
|
: taskStatusService.listStatuses(organizationId),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
|
if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
|
||||||
if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
|
if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
|
||||||
|
if (statRes.status === 'fulfilled') setTaskStatuses(statRes.value || [])
|
||||||
if (memRes.status === 'fulfilled') {
|
if (memRes.status === 'fulfilled') {
|
||||||
const memberList = memRes.value || []
|
const memberList = memRes.value || []
|
||||||
setMembers(memberList)
|
setMembers(memberList)
|
||||||
|
|
@ -427,10 +436,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
effectiveAssigneeUserId = undefined
|
effectiveAssigneeUserId = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchedStatus = taskStatuses.find((s) => s.name === status)
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
status,
|
status,
|
||||||
|
statusId: matchedStatus?.id,
|
||||||
projectId,
|
projectId,
|
||||||
categoryId,
|
categoryId,
|
||||||
assigneeUserId: effectiveAssigneeUserId,
|
assigneeUserId: effectiveAssigneeUserId,
|
||||||
|
|
@ -769,13 +781,23 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
</label>
|
</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(val) => setStatus(val as TaskStatus)}
|
onChange={(val) => setStatus(val)}
|
||||||
options={[
|
options={
|
||||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
taskStatuses.length > 0
|
||||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
? [...taskStatuses]
|
||||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
.sort((a, b) => a.position - b.position)
|
||||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
.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' },
|
||||||
|
]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,74 @@
|
||||||
import React, { useState } from 'react'
|
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 type { Task, TaskStatus } from '../../types/task'
|
||||||
import { TaskCard } from './TaskCard'
|
import { TaskCard } from './TaskCard'
|
||||||
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
|
||||||
|
|
||||||
interface TaskKanbanViewProps {
|
interface TaskKanbanViewProps {
|
||||||
tasks: Task[]
|
tasks: Task[]
|
||||||
|
taskStatuses?: TaskStatus[]
|
||||||
onToggleTimer: (task: Task) => void
|
onToggleTimer: (task: Task) => void
|
||||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
|
||||||
onEditTask: (task: Task) => void
|
onEditTask: (task: Task) => void
|
||||||
onOpenHistory?: (task: Task) => void
|
onOpenHistory?: (task: Task) => void
|
||||||
onDeleteTask?: (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
|
activeTaskId?: number | null
|
||||||
activeTaskSeconds?: number
|
activeTaskSeconds?: number
|
||||||
hiddenColumns?: TaskStatus[]
|
hiddenColumns?: string[]
|
||||||
showAllColumns?: boolean
|
showAllColumns?: boolean
|
||||||
onHideColumn?: (status: TaskStatus) => void
|
onHideColumn?: (status: string) => void
|
||||||
onUnhideColumn?: (status: TaskStatus) => void
|
onUnhideColumn?: (status: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
status: TaskStatus
|
id?: number
|
||||||
|
status: string
|
||||||
label: string
|
label: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
colorClass: string
|
color: string
|
||||||
bgLightClass: string
|
colorClass?: string
|
||||||
borderColor: 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',
|
label: 'A Fazer',
|
||||||
icon: CircleDot,
|
icon: CircleDot,
|
||||||
|
color: '#71717a',
|
||||||
colorClass: 'text-muted',
|
colorClass: 'text-muted',
|
||||||
bgLightClass: 'bg-surface-sunken',
|
bgLightClass: 'bg-surface-sunken',
|
||||||
borderColor: 'border-border-soft',
|
borderColor: 'border-border-soft',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'IN_PROGRESS',
|
status: 'Em Andamento',
|
||||||
label: 'Em Andamento',
|
label: 'Em Andamento',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
color: '#14b8a6',
|
||||||
colorClass: 'text-teal-700 dark:text-teal-400',
|
colorClass: 'text-teal-700 dark:text-teal-400',
|
||||||
bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15',
|
bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15',
|
||||||
borderColor: 'border-teal-600/25 dark:border-teal-500/20',
|
borderColor: 'border-teal-600/25 dark:border-teal-500/20',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'COMPLETED',
|
status: 'Concluído',
|
||||||
label: 'Concluído',
|
label: 'Concluído',
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
|
color: '#10b981',
|
||||||
colorClass: 'text-emerald-700 dark:text-emerald-400',
|
colorClass: 'text-emerald-700 dark:text-emerald-400',
|
||||||
bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15',
|
bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15',
|
||||||
borderColor: 'border-emerald-600/25 dark:border-emerald-500/20',
|
borderColor: 'border-emerald-600/25 dark:border-emerald-500/20',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'CANCELED',
|
status: 'Cancelado',
|
||||||
label: 'Cancelado',
|
label: 'Cancelado',
|
||||||
icon: Ban,
|
icon: Ban,
|
||||||
|
color: '#f43f5e',
|
||||||
colorClass: 'text-rose-700 dark:text-rose-400',
|
colorClass: 'text-rose-700 dark:text-rose-400',
|
||||||
bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15',
|
bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15',
|
||||||
borderColor: 'border-rose-600/25 dark:border-rose-500/20',
|
borderColor: 'border-rose-600/25 dark:border-rose-500/20',
|
||||||
|
|
@ -66,12 +77,16 @@ export const COLUMNS: ColumnDef[] = [
|
||||||
|
|
||||||
export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
tasks,
|
tasks,
|
||||||
|
taskStatuses = [],
|
||||||
onToggleTimer,
|
onToggleTimer,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onAddTaskToStatus,
|
onAddTaskToStatus,
|
||||||
|
onOpenCreateStatus,
|
||||||
|
onEditStatus,
|
||||||
|
onDeleteStatus,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
activeTaskSeconds,
|
activeTaskSeconds,
|
||||||
hiddenColumns = [],
|
hiddenColumns = [],
|
||||||
|
|
@ -79,20 +94,44 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
onHideColumn,
|
onHideColumn,
|
||||||
onUnhideColumn,
|
onUnhideColumn,
|
||||||
}) => {
|
}) => {
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null)
|
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
|
||||||
const [columnMenuOpen, setColumnMenuOpen] = useState<TaskStatus | null>(null)
|
const [columnMenuOpen, setColumnMenuOpen] = useState<string | null>(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
|
// Visible columns based on hidden state unless showAllColumns is active
|
||||||
const visibleColumns = COLUMNS.filter((col) => {
|
const visibleColumns = columns.filter((col) => {
|
||||||
if (showAllColumns) return true
|
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.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'move'
|
e.dataTransfer.dropEffect = 'move'
|
||||||
if (dragOverColumn !== status) {
|
if (dragOverColumn !== statusKey) {
|
||||||
setDragOverColumn(status)
|
setDragOverColumn(statusKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +140,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
setDragOverColumn(null)
|
setDragOverColumn(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => {
|
const handleDrop = (e: React.DragEvent, targetCol: ColumnDef) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragOverColumn(null)
|
setDragOverColumn(null)
|
||||||
const taskIdStr = e.dataTransfer.getData('text/plain')
|
const taskIdStr = e.dataTransfer.getData('text/plain')
|
||||||
|
|
@ -109,16 +148,15 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
|
|
||||||
const taskId = Number(taskIdStr)
|
const taskId = Number(taskIdStr)
|
||||||
const targetTask = tasks.find((t) => t.id === taskId)
|
const targetTask = tasks.find((t) => t.id === taskId)
|
||||||
if (targetTask && targetTask.status !== targetStatus) {
|
if (!targetTask) return
|
||||||
onStatusChange(targetTask, targetStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGridColsClass = (count: number) => {
|
const isSameStatus =
|
||||||
if (count <= 1) return 'grid-cols-1'
|
(targetCol.id && targetTask.taskStatus?.id === targetCol.id) ||
|
||||||
if (count === 2) return 'grid-cols-1 md:grid-cols-2'
|
targetTask.status === targetCol.status
|
||||||
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'
|
if (!isSameStatus) {
|
||||||
|
onStatusChange(targetTask, targetCol.status, targetCol.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleColumns.length === 0) {
|
if (visibleColumns.length === 0) {
|
||||||
|
|
@ -133,11 +171,42 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWideBoard = visibleColumns.length > 4
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid ${getGridColsClass(visibleColumns.length)} gap-4 items-start pb-6`}>
|
<div
|
||||||
|
className={
|
||||||
|
isWideBoard
|
||||||
|
? 'flex gap-4 overflow-x-auto pb-6 items-start scrollbar-thin'
|
||||||
|
: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-start pb-6'
|
||||||
|
}
|
||||||
|
>
|
||||||
{visibleColumns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const columnTasks = tasks.filter((t) => t.status === col.status)
|
const columnTasks = tasks.filter((t) => {
|
||||||
const isHidden = hiddenColumns.includes(col.status)
|
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(
|
const colTotalSeconds = columnTasks.reduce(
|
||||||
(acc, t) =>
|
(acc, t) =>
|
||||||
acc +
|
acc +
|
||||||
|
|
@ -152,23 +221,28 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.status}
|
key={col.id ? `col-${col.id}` : `col-${col.status}`}
|
||||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||||
onDragLeave={handleDragLeave}
|
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 ${
|
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' : ''
|
isWideBoard ? 'w-80 shrink-0' : 'w-full'
|
||||||
} ${
|
} ${isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''} ${
|
||||||
isOver
|
isOver ? 'ring-2 ring-brand/30 bg-brand/5' : ''
|
||||||
? 'border-brand ring-2 ring-brand/30 bg-brand/5'
|
|
||||||
: col.borderColor
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
<div className="shrink-0 flex items-center justify-between p-3.5 border-b border-border-soft bg-surface/40">
|
<div className="shrink-0 flex items-center justify-between p-3.5 border-b border-border-soft bg-surface/40">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`w-7 h-7 rounded-lg ${col.bgLightClass} flex items-center justify-center shrink-0 ${col.colorClass}`}
|
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${col.color}20`,
|
||||||
|
color: col.color,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,14 +269,14 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddTaskToStatus(col.status)}
|
onClick={() => onAddTaskToStatus(col.status, col.id)}
|
||||||
title={`Adicionar tarefa em ${col.label}`}
|
title={`Adicionar tarefa em ${col.label}`}
|
||||||
className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Column options menu (hide/unhide) */}
|
{/* Column options menu */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -226,6 +300,21 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-8 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
|
<div className="absolute right-0 top-8 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
|
||||||
|
{col.rawStatus && onEditStatus && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setColumnMenuOpen(null)
|
||||||
|
onEditStatus(col.rawStatus!)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-foreground hover:bg-surface-sunken transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-muted" />
|
||||||
|
<span>Editar Status</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isHidden ? (
|
{isHidden ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -247,12 +336,27 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
onHideColumn?.(col.status)
|
onHideColumn?.(col.status)
|
||||||
setColumnMenuOpen(null)
|
setColumnMenuOpen(null)
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-rose-400 hover:bg-surface-sunken transition-colors"
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-amber-500 hover:bg-surface-sunken transition-colors"
|
||||||
>
|
>
|
||||||
<EyeOff className="w-3.5 h-3.5" />
|
<EyeOff className="w-3.5 h-3.5" />
|
||||||
<span>Ocultar coluna</span>
|
<span>Ocultar coluna</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{col.rawStatus && onDeleteStatus && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setColumnMenuOpen(null)
|
||||||
|
onDeleteStatus(col.rawStatus!)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-rose-400 hover:bg-surface-sunken transition-colors border-t border-border-soft mt-1 pt-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Excluir Status</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -281,7 +385,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
|
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddTaskToStatus(col.status)}
|
onClick={() => onAddTaskToStatus(col.status, col.id)}
|
||||||
className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
|
className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
|
|
@ -293,6 +397,23 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Add new column button */}
|
||||||
|
{onOpenCreateStatus && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCreateStatus}
|
||||||
|
className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-border-soft hover:border-brand/50 hover:bg-brand/5 text-muted hover:text-brand transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] w-72 shrink-0 p-6 group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-surface-raised border border-border-soft flex items-center justify-center text-muted group-hover:text-brand group-hover:border-brand/40 group-hover:scale-110 transition-all mb-3 shadow-xs">
|
||||||
|
<Plus className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-sm">Novo Status</span>
|
||||||
|
<span className="text-xs text-muted text-center mt-1">
|
||||||
|
Adicione uma nova coluna ao quadro da sua organização
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import { canDeleteTask } from '../../lib/taskPermissions'
|
||||||
|
|
||||||
interface TaskListViewProps {
|
interface TaskListViewProps {
|
||||||
tasks: Task[]
|
tasks: Task[]
|
||||||
|
taskStatuses?: TaskStatus[]
|
||||||
onToggleTimer: (task: Task) => void
|
onToggleTimer: (task: Task) => void
|
||||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
|
||||||
onEditTask: (task: Task) => void
|
onEditTask: (task: Task) => void
|
||||||
onOpenHistory?: (task: Task) => void
|
onOpenHistory?: (task: Task) => void
|
||||||
onDeleteTask?: (task: Task) => void
|
onDeleteTask?: (task: Task) => void
|
||||||
|
|
@ -20,6 +21,7 @@ interface TaskListViewProps {
|
||||||
|
|
||||||
export const TaskListView: React.FC<TaskListViewProps> = ({
|
export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
tasks,
|
tasks,
|
||||||
|
taskStatuses = [],
|
||||||
onToggleTimer,
|
onToggleTimer,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
|
|
@ -29,6 +31,24 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
activeTaskSeconds,
|
activeTaskSeconds,
|
||||||
}) => {
|
}) => {
|
||||||
const { user, activeOrg } = useAuth()
|
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<number | null>(null)
|
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -151,14 +171,12 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
<div className="w-36">
|
<div className="w-36">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
size="sm"
|
size="sm"
|
||||||
value={task.status}
|
value={task.taskStatus?.name || task.status}
|
||||||
onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)}
|
onChange={(newStatus) => {
|
||||||
options={[
|
const matched = taskStatuses.find((s) => s.name === newStatus)
|
||||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
onStatusChange(task, newStatus, matched?.id)
|
||||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
}}
|
||||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
options={statusOptions}
|
||||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { authService } from '../services/authService'
|
import { authService } from '../services/authService'
|
||||||
import type { TaskStatus } from '../types/task'
|
|
||||||
|
|
||||||
export type DashboardWidgetId =
|
export type DashboardWidgetId =
|
||||||
| 'kpis'
|
| 'kpis'
|
||||||
|
|
@ -110,7 +109,7 @@ export interface UserPreferences {
|
||||||
targetHours: number
|
targetHours: number
|
||||||
}
|
}
|
||||||
kanban?: {
|
kanban?: {
|
||||||
hiddenColumns?: TaskStatus[]
|
hiddenColumns?: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,8 @@ export const DashboardPage: React.FC = () => {
|
||||||
const handleModalSubmit = async (taskData: {
|
const handleModalSubmit = async (taskData: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'
|
status: string
|
||||||
|
statusId?: number
|
||||||
projectId?: number
|
projectId?: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
assigneeUserId?: number
|
assigneeUserId?: number
|
||||||
|
|
@ -345,6 +346,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: taskData.status,
|
status: taskData.status,
|
||||||
|
statusId: taskData.statusId,
|
||||||
projectId: taskData.projectId,
|
projectId: taskData.projectId,
|
||||||
categoryId: taskData.categoryId,
|
categoryId: taskData.categoryId,
|
||||||
assigneeUserId: taskData.assigneeUserId,
|
assigneeUserId: taskData.assigneeUserId,
|
||||||
|
|
|
||||||
625
frontend/src/pages/StatusesPage.tsx
Normal file
625
frontend/src/pages/StatusesPage.tsx
Normal file
|
|
@ -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<TaskStatus[]>([])
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create / Edit Modal
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [editingStatus, setEditingStatus] = useState<TaskStatus | null>(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<TaskStatus | null>(null)
|
||||||
|
const [moveToStatusId, setMoveToStatusId] = useState<number | undefined>(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<number, { totalTasks: number; totalSeconds: number }>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-border pb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
|
||||||
|
<span>Estados das Tarefas</span>
|
||||||
|
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-brand-soft text-brand border border-brand/20">
|
||||||
|
{statuses.length}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-muted mt-1">
|
||||||
|
Personalize as colunas e os fluxos de trabalho da organização {activeOrg?.name}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="p-2 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||||
|
title="Atualizar"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateModal}
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-xl bg-gradient-to-r from-accent to-brand text-accent-foreground font-semibold text-xs shadow-md shadow-accent/20 hover:opacity-95 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Novo Status</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3.5 rounded-2xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
<button type="button" onClick={() => setError(null)} className="ml-auto text-muted hover:text-foreground">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Actions Bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar status..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Statuses */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-32 rounded-3xl border border-border-soft bg-surface-sunken/40 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredStatuses.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center rounded-3xl border border-dashed border-border-soft bg-surface/40">
|
||||||
|
<CircleDot className="w-10 h-10 text-muted/60 mb-3" />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-1">Nenhum status encontrado</h3>
|
||||||
|
<p className="text-xs text-muted mb-4 max-w-sm">
|
||||||
|
{searchQuery
|
||||||
|
? 'Nenhum status corresponde à busca atual.'
|
||||||
|
: 'Crie seu primeiro status para gerenciar o fluxo das tarefas da sua organização.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateModal}
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-brand text-brand-foreground font-semibold text-xs shadow-md shadow-brand/20 hover:opacity-95 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Criar Status</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={st.id}
|
||||||
|
className="group relative rounded-3xl border border-border-soft bg-surface/70 backdrop-blur-md p-4 transition-all duration-200 hover:shadow-xl hover:-translate-y-0.5 hover:border-border flex flex-col justify-between"
|
||||||
|
style={{
|
||||||
|
borderLeftWidth: '4px',
|
||||||
|
borderLeftColor: st.color || '#71717a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-xl flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${st.color}20`,
|
||||||
|
color: st.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-sm text-foreground truncate">{st.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mt-0.5">
|
||||||
|
{st.isDefault && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-md text-[10px] font-bold bg-brand-soft text-brand border border-brand/20">
|
||||||
|
Padrão
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{st.isCompleted && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-md text-[10px] font-bold bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
|
||||||
|
Conclusão
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{st.isCanceled && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-md text-[10px] font-bold bg-rose-500/15 text-rose-500 border border-rose-500/20">
|
||||||
|
Cancelamento
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order buttons & Edit */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveOrder(index, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
title="Mover para a esquerda / cima"
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||||
|
>
|
||||||
|
<MoveUp className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveOrder(index, 'down')}
|
||||||
|
disabled={index === filteredStatuses.length - 1}
|
||||||
|
title="Mover para a direita / baixo"
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||||
|
>
|
||||||
|
<MoveDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditModal(st)}
|
||||||
|
title="Editar status"
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-brand hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{statuses.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenDeleteModal(st)}
|
||||||
|
title="Excluir status"
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-rose-400 hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-4 pt-3 border-t border-border-soft">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<ListTodo className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground font-mono">{stats.totalTasks}</strong> tarefas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground font-mono">{formatSecondsToHuman(stats.totalSeconds)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>Posição no Kanban: {st.position + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/tasks?scope=team`)}
|
||||||
|
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||||
|
>
|
||||||
|
<span>Ver no quadro</span>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Criar/Editar */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-soft pb-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||||
|
<CircleDot className="w-4 h-4 text-brand" />
|
||||||
|
<span>{editingStatus ? 'Editar Status' : 'Novo Status'}</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="w-7 h-7 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Nome do Status</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Em Teste, Homologação, Bloqueado"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Cor da Coluna</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{COLOR_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(preset)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-all flex items-center justify-center ${
|
||||||
|
color === preset ? 'border-foreground scale-110 shadow-xs' : 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: preset }}
|
||||||
|
>
|
||||||
|
{color === preset && <Check className="w-3.5 h-3.5 text-white drop-shadow-md" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Behavior Flags */}
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border-soft">
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1">Comportamento</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDefault}
|
||||||
|
onChange={(e) => setIsDefault(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-brand focus:ring-brand"
|
||||||
|
/>
|
||||||
|
<span>Status inicial padrão para novas tarefas</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isCompleted}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsCompleted(e.target.checked)
|
||||||
|
if (e.target.checked) setIsCanceled(false)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-emerald-500 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span>Indica que a tarefa foi concluída</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isCanceled}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsCanceled(e.target.checked)
|
||||||
|
if (e.target.checked) setIsCompleted(false)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-rose-500 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
<span>Indica que a tarefa foi cancelada</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !name.trim()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-accent to-brand text-accent-foreground font-semibold text-xs shadow-md shadow-accent/20 hover:opacity-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
<span>{editingStatus ? 'Salvar Alterações' : 'Criar Status'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Excluir com Reatribuição */}
|
||||||
|
{deletingStatus && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-rose-500/15 text-rose-500 flex items-center justify-center shrink-0">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-foreground">Excluir Status "{deletingStatus.name}"?</h3>
|
||||||
|
<p className="text-xs text-muted">Esta ação remove o status e a coluna correspondente no Kanban.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const taskCount = statusStats.get(deletingStatus.id)?.totalTasks || 0
|
||||||
|
const otherStatuses = statuses.filter((s) => s.id !== deletingStatus.id)
|
||||||
|
|
||||||
|
if (taskCount > 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-500">
|
||||||
|
Existem <strong>{taskCount}</strong> tarefas neste status. Escolha para qual status elas devem ser
|
||||||
|
movidas:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Mover tarefas para:</label>
|
||||||
|
<select
|
||||||
|
value={moveToStatusId}
|
||||||
|
onChange={(e) => setMoveToStatusId(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{otherStatuses.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted">Não há tarefas vinculadas a este status. Ele pode ser excluído com segurança.</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingStatus(null)}
|
||||||
|
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-600 text-white font-semibold text-xs shadow-md shadow-rose-600/20 hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
<span>Excluir Status</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,23 +9,25 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Folder,
|
Folder,
|
||||||
Users,
|
Users,
|
||||||
User,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Ban,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
User as UserIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTimer } from '../contexts/TimerContext'
|
import { useTimer } from '../contexts/TimerContext'
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
import { projectService } from '../services/projectService'
|
import { projectService } from '../services/projectService'
|
||||||
|
import { taskStatusService } from '../services/taskStatusService'
|
||||||
import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task'
|
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 { TaskListView } from '../components/tasks/TaskListView'
|
||||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||||
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
|
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
|
||||||
|
|
@ -36,6 +38,19 @@ import {
|
||||||
savePreferencesToBackend,
|
savePreferencesToBackend,
|
||||||
} from '../lib/userPreferences'
|
} from '../lib/userPreferences'
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#71717a',
|
||||||
|
'#0f766e',
|
||||||
|
'#10b981',
|
||||||
|
'#3b82f6',
|
||||||
|
'#6366f1',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#ec4899',
|
||||||
|
'#f43f5e',
|
||||||
|
'#f59e0b',
|
||||||
|
'#64748b',
|
||||||
|
]
|
||||||
|
|
||||||
export const TasksPage: React.FC = () => {
|
export const TasksPage: React.FC = () => {
|
||||||
const { activeOrg, user } = useAuth()
|
const { activeOrg, user } = useAuth()
|
||||||
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
||||||
|
|
@ -47,18 +62,19 @@ export const TasksPage: React.FC = () => {
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban')
|
const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
// Kanban column visibility preferences
|
// Kanban column visibility preferences
|
||||||
const [hiddenColumns, setHiddenColumns] = useState<TaskStatus[]>([])
|
const [hiddenColumns, setHiddenColumns] = useState<string[]>([])
|
||||||
const [showAllColumns, setShowAllColumns] = useState(false)
|
const [showAllColumns, setShowAllColumns] = useState(false)
|
||||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedStatus, setSelectedStatus] = useState<TaskStatus | 'ALL'>('ALL')
|
const [selectedStatus, setSelectedStatus] = useState<string | 'ALL'>('ALL')
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<number | 'ALL'>('ALL')
|
const [selectedProjectId, setSelectedProjectId] = useState<number | 'ALL'>('ALL')
|
||||||
|
|
||||||
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
|
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
|
||||||
|
|
@ -86,13 +102,28 @@ export const TasksPage: React.FC = () => {
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED')
|
const [modalInitialStatus, setModalInitialStatus] = useState<string>('A Fazer')
|
||||||
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
|
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||||
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
|
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
|
||||||
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
|
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Status Quick Create / Edit Modal State
|
||||||
|
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false)
|
||||||
|
const [editingStatus, setEditingStatus] = useState<TaskStatus | null>(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<TaskStatus | null>(null)
|
||||||
|
const [moveToStatusId, setMoveToStatusId] = useState<number | undefined>(undefined)
|
||||||
|
const [deletingStatusLoading, setDeletingStatusLoading] = useState(false)
|
||||||
|
|
||||||
// Load user preferences for hidden columns from backend
|
// Load user preferences for hidden columns from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.email) return
|
if (!user?.email) return
|
||||||
|
|
@ -112,7 +143,7 @@ export const TasksPage: React.FC = () => {
|
||||||
|
|
||||||
// Save hidden columns to backend
|
// Save hidden columns to backend
|
||||||
const updateHiddenColumns = useCallback(
|
const updateHiddenColumns = useCallback(
|
||||||
(newHidden: TaskStatus[]) => {
|
(newHidden: string[]) => {
|
||||||
setHiddenColumns(newHidden)
|
setHiddenColumns(newHidden)
|
||||||
if (!user?.email) return
|
if (!user?.email) return
|
||||||
const current = getLocalPreferences(user.email)
|
const current = getLocalPreferences(user.email)
|
||||||
|
|
@ -131,7 +162,7 @@ export const TasksPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleHideColumn = useCallback(
|
const handleHideColumn = useCallback(
|
||||||
(status: TaskStatus) => {
|
(status: string) => {
|
||||||
if (!hiddenColumns.includes(status)) {
|
if (!hiddenColumns.includes(status)) {
|
||||||
updateHiddenColumns([...hiddenColumns, status])
|
updateHiddenColumns([...hiddenColumns, status])
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +171,7 @@ export const TasksPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleUnhideColumn = useCallback(
|
const handleUnhideColumn = useCallback(
|
||||||
(status: TaskStatus) => {
|
(status: string) => {
|
||||||
updateHiddenColumns(hiddenColumns.filter((s) => s !== status))
|
updateHiddenColumns(hiddenColumns.filter((s) => s !== status))
|
||||||
},
|
},
|
||||||
[hiddenColumns, updateHiddenColumns]
|
[hiddenColumns, updateHiddenColumns]
|
||||||
|
|
@ -152,18 +183,20 @@ export const TasksPage: React.FC = () => {
|
||||||
|
|
||||||
const activeOrgId = activeOrg?.id
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
// Load tasks & projects for current organization
|
// Load tasks, projects & statuses for current organization
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!activeOrgId) return
|
if (!activeOrgId) return
|
||||||
try {
|
try {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
const [tasksRes, projsRes] = await Promise.all([
|
const [tasksRes, projsRes, statusesRes] = await Promise.all([
|
||||||
taskService.listTasks({ organizationId: activeOrgId }),
|
taskService.listTasks({ organizationId: activeOrgId }),
|
||||||
projectService.listProjects(activeOrgId).catch(() => []),
|
projectService.listProjects(activeOrgId).catch(() => []),
|
||||||
|
taskStatusService.listStatuses(activeOrgId).catch(() => []),
|
||||||
])
|
])
|
||||||
|
|
||||||
setTasks(tasksRes || [])
|
setTasks(tasksRes || [])
|
||||||
setProjects(projsRes || [])
|
setProjects(projsRes || [])
|
||||||
|
setTaskStatuses(statusesRes || [])
|
||||||
syncWithTasksRef.current(tasksRes || [])
|
syncWithTasksRef.current(tasksRes || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tasks', err)
|
console.error('Failed to load tasks', err)
|
||||||
|
|
@ -192,13 +225,39 @@ export const TasksPage: React.FC = () => {
|
||||||
// Scope tasks based on viewScope
|
// Scope tasks based on viewScope
|
||||||
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
|
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
|
// Filter tasks client-side for ultra-responsive search
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
return scopeTasks.filter((task) => {
|
return scopeTasks.filter((task) => {
|
||||||
// Status filter
|
// Status filter
|
||||||
if (selectedStatus !== 'ALL' && task.status !== selectedStatus) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Project filter
|
// Project filter
|
||||||
if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) {
|
if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -241,14 +300,24 @@ export const TasksPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status change handler
|
// Status change handler
|
||||||
const handleStatusChange = async (task: Task, newStatus: TaskStatus) => {
|
const handleStatusChange = async (task: Task, newStatus: string, newStatusId?: number) => {
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
|
const matchedStatus = taskStatuses.find((s) => s.id === newStatusId || s.name === newStatus)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setTasks((prev) =>
|
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)))
|
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to update status', err)
|
console.error('Failed to update status', err)
|
||||||
|
|
@ -261,7 +330,8 @@ export const TasksPage: React.FC = () => {
|
||||||
const handleModalSubmit = async (taskData: {
|
const handleModalSubmit = async (taskData: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
status: TaskStatus
|
status: string
|
||||||
|
statusId?: number
|
||||||
projectId?: number
|
projectId?: number
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
assigneeUserId?: number
|
assigneeUserId?: number
|
||||||
|
|
@ -276,6 +346,7 @@ export const TasksPage: React.FC = () => {
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: taskData.status,
|
status: taskData.status,
|
||||||
|
statusId: taskData.statusId,
|
||||||
projectId: taskData.projectId,
|
projectId: taskData.projectId,
|
||||||
categoryId: taskData.categoryId,
|
categoryId: taskData.categoryId,
|
||||||
assigneeUserId: taskData.assigneeUserId,
|
assigneeUserId: taskData.assigneeUserId,
|
||||||
|
|
@ -291,6 +362,7 @@ export const TasksPage: React.FC = () => {
|
||||||
title: taskData.title,
|
title: taskData.title,
|
||||||
description: taskData.description,
|
description: taskData.description,
|
||||||
status: taskData.status,
|
status: taskData.status,
|
||||||
|
statusId: taskData.statusId,
|
||||||
projectId: taskData.projectId,
|
projectId: taskData.projectId,
|
||||||
categoryId: taskData.categoryId,
|
categoryId: taskData.categoryId,
|
||||||
assigneeUserId: taskData.assigneeUserId,
|
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)
|
setEditingTask(null)
|
||||||
setModalInitialStatus(initialStatus)
|
setModalInitialStatus(initialStatus)
|
||||||
setModalInitialTab('details')
|
setModalInitialTab('details')
|
||||||
|
|
@ -343,11 +415,80 @@ export const TasksPage: React.FC = () => {
|
||||||
[activeTask, stopTimer, editingTask]
|
[activeTask, stopTimer, editingTask]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Summary counts based on active scope
|
// Status modal handlers
|
||||||
const notStartedCount = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
|
const handleOpenStatusModal = (st: TaskStatus | null) => {
|
||||||
const inProgressCount = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
if (st) {
|
||||||
const completedCount = scopeTasks.filter((t) => t.status === 'COMPLETED').length
|
setEditingStatus(st)
|
||||||
const canceledCount = scopeTasks.filter((t) => t.status === 'CANCELED').length
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
|
@ -358,124 +499,122 @@ export const TasksPage: React.FC = () => {
|
||||||
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-brand via-brand-strong to-accent bg-clip-text text-transparent">
|
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-brand via-brand-strong to-accent bg-clip-text text-transparent">
|
||||||
Quadro de Tarefas
|
Quadro de Tarefas
|
||||||
</h1>
|
</h1>
|
||||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25 shadow-xs">
|
<span className="text-[11px] font-bold px-2 py-0.5 rounded-full bg-brand/10 text-brand border border-brand/20">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
{activeOrg?.name || 'Workspace'}
|
{activeOrg?.name || 'Workspace'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs sm:text-sm text-muted">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Switcher & New Task Button */}
|
{/* View Mode & Actions */}
|
||||||
<div className="flex items-center gap-2.5 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{/* Refresh */}
|
{/* Refresh button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
title="Recarregar tarefas"
|
className="p-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||||
className="p-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-panel disabled:opacity-50"
|
title="Atualizar tarefas"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kanban / List Toggle */}
|
{/* View toggle (Kanban vs List) */}
|
||||||
<div className="flex items-center p-1 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel">
|
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode('kanban')}
|
onClick={() => setViewMode('kanban')}
|
||||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
viewMode === 'kanban'
|
viewMode === 'kanban'
|
||||||
? 'bg-brand text-white shadow-md shadow-brand/20'
|
? 'bg-brand text-white shadow-xs'
|
||||||
: 'text-muted hover:text-foreground'
|
: 'text-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
|
title="Visualização em Quadro (Kanban)"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="w-3.5 h-3.5" />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
<span>Kanban</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
viewMode === 'list'
|
viewMode === 'list'
|
||||||
? 'bg-brand text-white shadow-md shadow-brand/20'
|
? 'bg-brand text-white shadow-xs'
|
||||||
: 'text-muted hover:text-foreground'
|
: 'text-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
|
title="Visualização em Lista"
|
||||||
>
|
>
|
||||||
<ListIcon className="w-3.5 h-3.5" />
|
<ListIcon className="w-4 h-4" />
|
||||||
<span>Lista</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kanban Columns Visibility Control */}
|
{/* Kanban Columns Settings (Only in Kanban Mode) */}
|
||||||
{viewMode === 'kanban' && (
|
{viewMode === 'kanban' && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowColumnSettings(!showColumnSettings)}
|
onClick={() => setShowColumnSettings(!showColumnSettings)}
|
||||||
title="Gerenciar colunas do Kanban"
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-semibold transition-all ${
|
||||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-medium transition-all shadow-panel ${
|
|
||||||
hiddenColumns.length > 0
|
hiddenColumns.length > 0
|
||||||
? 'border-brand/40 bg-brand/10 text-brand'
|
? 'border-amber-500/50 bg-amber-500/10 text-amber-500 ring-1 ring-amber-500/30'
|
||||||
: 'border-border-soft bg-surface/90 text-muted hover:text-foreground hover:bg-surface-raised'
|
: 'border-border-soft bg-surface text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
}`}
|
}`}
|
||||||
|
title="Personalizar visualização das colunas"
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||||
<span>Colunas</span>
|
<span className="hidden sm:inline">Colunas</span>
|
||||||
{hiddenColumns.length > 0 && (
|
{hiddenColumns.length > 0 && (
|
||||||
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-brand text-white font-bold">
|
<span className="w-4 h-4 rounded-full bg-amber-500 text-black text-[10px] font-bold flex items-center justify-center">
|
||||||
{hiddenColumns.length} oculta{hiddenColumns.length > 1 ? 's' : ''}
|
{hiddenColumns.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Column Settings Dropdown */}
|
||||||
{showColumnSettings && (
|
{showColumnSettings && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40"
|
className="fixed inset-0 z-40"
|
||||||
onClick={() => setShowColumnSettings(false)}
|
onClick={() => setShowColumnSettings(false)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-11 z-50 w-64 rounded-2xl border border-border bg-surface-raised p-3 shadow-2xl space-y-2.5">
|
<div className="absolute right-0 top-11 z-50 w-72 rounded-2xl border border-border bg-surface-raised p-3 shadow-2xl space-y-3 animate-in fade-in zoom-in-95 duration-150">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
<div className="flex items-center justify-between border-b border-border-soft pb-2">
|
||||||
<span className="text-xs font-bold text-foreground">Visibilidade de Colunas</span>
|
<div className="flex items-center gap-1.5">
|
||||||
{hiddenColumns.length > 0 && (
|
<SlidersHorizontal className="w-3.5 h-3.5 text-brand" />
|
||||||
<button
|
<h4 className="text-xs font-bold text-foreground">Colunas do Quadro</h4>
|
||||||
type="button"
|
</div>
|
||||||
onClick={handleUnhideAllColumns}
|
<span className="text-[10px] text-muted font-medium">
|
||||||
className="text-[11px] text-brand hover:underline font-medium"
|
{effectiveColumns.length - hiddenColumns.length} de {effectiveColumns.length} ativas
|
||||||
>
|
</span>
|
||||||
Restaurar todas
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle show all columns temporarily */}
|
{/* Master toggle */}
|
||||||
<div className="flex items-center justify-between p-2 rounded-xl bg-surface border border-border-soft">
|
<div className="flex items-center justify-between text-xs py-1">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-muted text-[11px]">Exibir todas as colunas:</span>
|
||||||
{showAllColumns ? (
|
<button
|
||||||
<Eye className="w-3.5 h-3.5 text-brand" />
|
type="button"
|
||||||
) : (
|
onClick={() => {
|
||||||
<EyeOff className="w-3.5 h-3.5 text-muted" />
|
if (showAllColumns) {
|
||||||
)}
|
setShowAllColumns(false)
|
||||||
<span className="text-xs font-medium text-foreground">Mostrar ocultas</span>
|
} else {
|
||||||
</div>
|
handleUnhideAllColumns()
|
||||||
<input
|
setShowAllColumns(false)
|
||||||
type="checkbox"
|
}
|
||||||
checked={showAllColumns}
|
}}
|
||||||
onChange={(e) => setShowAllColumns(e.target.checked)}
|
className="text-xs font-semibold text-brand hover:underline"
|
||||||
className="w-4 h-4 rounded text-brand border-border focus:ring-brand"
|
>
|
||||||
/>
|
{hiddenColumns.length === 0 ? 'Todas visíveis' : 'Reexibir todas'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Individual columns list */}
|
{/* Individual columns list */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||||
{COLUMNS.map((col) => {
|
{effectiveColumns.map((col) => {
|
||||||
const isHidden = hiddenColumns.includes(col.status)
|
const isHidden = hiddenColumns.includes(col.status)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={col.status}
|
key={col.id ? `col-${col.id}` : col.status}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
|
|
@ -490,22 +629,21 @@ export const TasksPage: React.FC = () => {
|
||||||
: 'bg-surface hover:bg-surface-sunken text-foreground'
|
: 'bg-surface hover:bg-surface-sunken text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
isHidden ? 'bg-zinc-500' : 'bg-brand'
|
style={{ backgroundColor: col.color || '#71717a' }}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span>{col.label}</span>
|
<span className="truncate">{col.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHidden ? (
|
{isHidden ? (
|
||||||
<span className="flex items-center gap-1 text-[11px] text-amber-500 font-medium">
|
<span className="flex items-center gap-1 text-[11px] text-amber-500 font-medium shrink-0">
|
||||||
<EyeOff className="w-3.5 h-3.5" />
|
<EyeOff className="w-3.5 h-3.5" />
|
||||||
<span>Oculta</span>
|
<span>Oculta</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1 text-[11px] text-brand font-medium">
|
<span className="flex items-center gap-1 text-[11px] text-brand font-medium shrink-0">
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>Visível</span>
|
<span>Visível</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -514,6 +652,20 @@ export const TasksPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border-soft flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowColumnSettings(false)
|
||||||
|
handleOpenStatusModal(null)
|
||||||
|
}}
|
||||||
|
className="text-xs text-brand hover:underline font-semibold flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Novo Status</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -536,7 +688,8 @@ export const TasksPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAiDraftTask(null)
|
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"
|
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'
|
: 'text-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<User className="w-3.5 h-3.5" />
|
<UserIcon className="w-3.5 h-3.5" />
|
||||||
<span>Individual (Minhas Tarefas)</span>
|
<span>Minhas Tarefas</span>
|
||||||
<span
|
<span
|
||||||
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
|
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
|
||||||
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
|
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
|
||||||
|
|
@ -589,6 +742,7 @@ export const TasksPage: React.FC = () => {
|
||||||
{myTasks.length}
|
{myTasks.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleScopeChange('team')}
|
onClick={() => handleScopeChange('team')}
|
||||||
|
|
@ -646,7 +800,7 @@ export const TasksPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Statistics (Icon + Number) */}
|
{/* Status Statistics (Dynamic Pills) */}
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap py-1">
|
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap py-1">
|
||||||
{/* Total / All */}
|
{/* Total / All */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -663,65 +817,51 @@ export const TasksPage: React.FC = () => {
|
||||||
<span className="font-mono text-xs">{scopeTasks.length}</span>
|
<span className="font-mono text-xs">{scopeTasks.length}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 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
|
||||||
|
|
||||||
|
const isSelected = selectedStatus === col.status
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
|
key={col.id ? `pill-${col.id}` : `pill-${col.status}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedStatus(selectedStatus === 'NOT_STARTED' ? 'ALL' : 'NOT_STARTED')}
|
onClick={() => setSelectedStatus(isSelected ? 'ALL' : col.status)}
|
||||||
title="A Fazer"
|
title={col.label}
|
||||||
|
style={
|
||||||
|
isSelected
|
||||||
|
? {
|
||||||
|
backgroundColor: `${col.color}25`,
|
||||||
|
borderColor: col.color,
|
||||||
|
color: col.color,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
|
||||||
selectedStatus === 'NOT_STARTED'
|
isSelected
|
||||||
? 'bg-zinc-500/15 border-zinc-500/40 text-foreground ring-1 ring-zinc-500/30'
|
? 'shadow-xs ring-1'
|
||||||
: 'bg-surface-raised/60 border-border-soft text-muted hover:text-foreground hover:bg-surface-raised'
|
: 'bg-surface-raised/60 border-border-soft text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CircleDot className="w-3.5 h-3.5 text-muted shrink-0" />
|
<span
|
||||||
<span className="font-mono text-xs text-foreground">{notStartedCount}</span>
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
</button>
|
style={{ backgroundColor: col.color || '#71717a' }}
|
||||||
|
/>
|
||||||
{/* Em Andamento (IN_PROGRESS) */}
|
<span>{col.label}</span>
|
||||||
<button
|
<span className="font-mono text-xs">{count}</span>
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(selectedStatus === 'IN_PROGRESS' ? 'ALL' : 'IN_PROGRESS')}
|
|
||||||
title="Em Andamento"
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
|
|
||||||
selectedStatus === 'IN_PROGRESS'
|
|
||||||
? 'bg-teal-500/20 border-teal-500/50 text-teal-700 dark:text-teal-300 ring-1 ring-teal-500/30'
|
|
||||||
: 'bg-surface-raised/60 border-border-soft text-teal-700 dark:text-teal-400 hover:bg-surface-raised'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Clock className="w-3.5 h-3.5 text-teal-700 dark:text-teal-400 shrink-0" />
|
|
||||||
<span className="font-mono text-xs">{inProgressCount}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Concluído (COMPLETED) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(selectedStatus === 'COMPLETED' ? 'ALL' : 'COMPLETED')}
|
|
||||||
title="Concluído"
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
|
|
||||||
selectedStatus === 'COMPLETED'
|
|
||||||
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/30'
|
|
||||||
: 'bg-surface-raised/60 border-border-soft text-emerald-700 dark:text-emerald-400 hover:bg-surface-raised'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-700 dark:text-emerald-400 shrink-0" />
|
|
||||||
<span className="font-mono text-xs">{completedCount}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Cancelado (CANCELED) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(selectedStatus === 'CANCELED' ? 'ALL' : 'CANCELED')}
|
|
||||||
title="Cancelado"
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-semibold border transition-all cursor-pointer ${
|
|
||||||
selectedStatus === 'CANCELED'
|
|
||||||
? 'bg-rose-500/20 border-rose-500/50 text-rose-700 dark:text-rose-300 ring-1 ring-rose-500/30'
|
|
||||||
: 'bg-surface-raised/60 border-border-soft text-rose-700 dark:text-rose-400 hover:bg-surface-raised'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Ban className="w-3.5 h-3.5 text-rose-700 dark:text-rose-400 shrink-0" />
|
|
||||||
<span className="font-mono text-xs">{canceledCount}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -750,13 +890,14 @@ export const TasksPage: React.FC = () => {
|
||||||
<div className="w-44">
|
<div className="w-44">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
|
onChange={(val) => setSelectedStatus(val as string | 'ALL')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'ALL', label: 'Todos os Status' },
|
{ value: 'ALL', label: 'Todos os Status' },
|
||||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
...effectiveColumns.map((c) => ({
|
||||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
value: c.status,
|
||||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
label: c.label,
|
||||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
color: c.color,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -792,7 +933,7 @@ export const TasksPage: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenCreateModal('NOT_STARTED')}
|
onClick={() => handleOpenCreateModal('A Fazer')}
|
||||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all"
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
|
|
@ -802,12 +943,16 @@ export const TasksPage: React.FC = () => {
|
||||||
) : viewMode === 'kanban' ? (
|
) : viewMode === 'kanban' ? (
|
||||||
<TaskKanbanView
|
<TaskKanbanView
|
||||||
tasks={filteredTasks}
|
tasks={filteredTasks}
|
||||||
|
taskStatuses={taskStatuses}
|
||||||
onToggleTimer={handleToggleTimer}
|
onToggleTimer={handleToggleTimer}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onEditTask={handleOpenEditModal}
|
onEditTask={handleOpenEditModal}
|
||||||
onOpenHistory={handleOpenHistoryModal}
|
onOpenHistory={handleOpenHistoryModal}
|
||||||
onDeleteTask={handleDeleteTask}
|
onDeleteTask={handleDeleteTask}
|
||||||
onAddTaskToStatus={handleOpenCreateModal}
|
onAddTaskToStatus={handleOpenCreateModal}
|
||||||
|
onOpenCreateStatus={() => handleOpenStatusModal(null)}
|
||||||
|
onEditStatus={(st) => handleOpenStatusModal(st)}
|
||||||
|
onDeleteStatus={(st) => handleOpenDeleteStatusModal(st)}
|
||||||
activeTaskId={activeTask?.id}
|
activeTaskId={activeTask?.id}
|
||||||
activeTaskSeconds={activeSeconds}
|
activeTaskSeconds={activeSeconds}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
|
|
@ -818,6 +963,7 @@ export const TasksPage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<TaskListView
|
<TaskListView
|
||||||
tasks={filteredTasks}
|
tasks={filteredTasks}
|
||||||
|
taskStatuses={taskStatuses}
|
||||||
onToggleTimer={handleToggleTimer}
|
onToggleTimer={handleToggleTimer}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onEditTask={handleOpenEditModal}
|
onEditTask={handleOpenEditModal}
|
||||||
|
|
@ -844,6 +990,7 @@ export const TasksPage: React.FC = () => {
|
||||||
initialTask={editingTask}
|
initialTask={editingTask}
|
||||||
initialTab={modalInitialTab}
|
initialTab={modalInitialTab}
|
||||||
initialDraft={aiDraftTask}
|
initialDraft={aiDraftTask}
|
||||||
|
taskStatuses={taskStatuses}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -856,11 +1003,186 @@ export const TasksPage: React.FC = () => {
|
||||||
onSuccess={(parsed) => {
|
onSuccess={(parsed) => {
|
||||||
setAiDraftTask(parsed)
|
setAiDraftTask(parsed)
|
||||||
setEditingTask(null)
|
setEditingTask(null)
|
||||||
setModalInitialStatus(parsed.status || 'NOT_STARTED')
|
setModalInitialStatus(parsed.status || 'A Fazer')
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status Modal (Create / Edit from Kanban) */}
|
||||||
|
{isStatusModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-soft pb-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||||
|
<CircleDot className="w-4 h-4 text-brand" />
|
||||||
|
<span>{editingStatus ? 'Editar Status' : 'Novo Status do Quadro'}</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsStatusModalOpen(false)}
|
||||||
|
className="w-7 h-7 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveStatus} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Nome do Status</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Em Teste, Homologação, Bloqueado"
|
||||||
|
value={statusName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Cor da Coluna</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{COLOR_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusColor(preset)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-all flex items-center justify-center ${
|
||||||
|
statusColor === preset ? 'border-foreground scale-110 shadow-xs' : 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: preset }}
|
||||||
|
>
|
||||||
|
{statusColor === preset && <Check className="w-3.5 h-3.5 text-white drop-shadow-md" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Behavior Flags */}
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border-soft">
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1">Comportamento</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={statusIsDefault}
|
||||||
|
onChange={(e) => setStatusIsDefault(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-brand focus:ring-brand"
|
||||||
|
/>
|
||||||
|
<span>Status inicial padrão para novas tarefas</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={statusIsCompleted}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusIsCompleted(e.target.checked)
|
||||||
|
if (e.target.checked) setStatusIsCanceled(false)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-emerald-500 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span>Indica que a tarefa foi concluída</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={statusIsCanceled}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusIsCanceled(e.target.checked)
|
||||||
|
if (e.target.checked) setStatusIsCompleted(false)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded-md border-border text-rose-500 focus:ring-rose-500"
|
||||||
|
/>
|
||||||
|
<span>Indica que a tarefa foi cancelada</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsStatusModalOpen(false)}
|
||||||
|
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={statusSubmitting || !statusName.trim()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-accent to-brand text-accent-foreground font-semibold text-xs shadow-md shadow-accent/20 hover:opacity-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{statusSubmitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
<span>{editingStatus ? 'Salvar Alterações' : 'Criar Status'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Excluir Status com Reatribuição */}
|
||||||
|
{deletingStatus && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-rose-500/15 text-rose-500 flex items-center justify-center shrink-0">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-foreground">Excluir Status "{deletingStatus.name}"?</h3>
|
||||||
|
<p className="text-xs text-muted">Esta ação remove o status e a coluna correspondente no Kanban.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const otherStatuses = taskStatuses.filter((s) => s.id !== deletingStatus.id)
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-500">
|
||||||
|
Se houver tarefas vinculadas a este status, selecione para qual coluna elas serão movidas:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">Mover tarefas para:</label>
|
||||||
|
<select
|
||||||
|
value={moveToStatusId}
|
||||||
|
onChange={(e) => setMoveToStatusId(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{otherStatuses.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingStatus(null)}
|
||||||
|
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deletingStatusLoading}
|
||||||
|
onClick={handleConfirmDeleteStatus}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-600 text-white font-semibold text-xs shadow-md shadow-rose-600/20 hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingStatusLoading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
<span>Excluir Status</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { apiFetch } from '../lib/api'
|
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 {
|
function buildQueryParams(filter?: ListTasksFilter): string {
|
||||||
if (!filter) return ''
|
if (!filter) return ''
|
||||||
|
|
@ -57,9 +57,14 @@ export const taskService = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateStatus(id: number, status: TaskStatus): Promise<Task> {
|
async updateStatus(id: number, status: string, statusId?: number): Promise<Task> {
|
||||||
|
if (statusId) {
|
||||||
|
return apiFetch<Task>(`/tasks/${id}/status/${statusId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
}
|
||||||
return apiFetch<Task>(`/tasks/${id}/status`, {
|
return apiFetch<Task>(`/tasks/${id}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PUT',
|
||||||
body: status,
|
body: status,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
56
frontend/src/services/taskStatusService.ts
Normal file
56
frontend/src/services/taskStatusService.ts
Normal file
|
|
@ -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<TaskStatus[]> {
|
||||||
|
return apiFetch<TaskStatus[]>(`/organizations/${orgId}/task-statuses`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async createStatus(orgId: number, data: CreateTaskStatusData): Promise<TaskStatus> {
|
||||||
|
return apiFetch<TaskStatus>(`/organizations/${orgId}/task-statuses`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(orgId: number, id: number, data: UpdateTaskStatusData): Promise<TaskStatus> {
|
||||||
|
return apiFetch<TaskStatus>(`/organizations/${orgId}/task-statuses/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteStatus(orgId: number, id: number, moveToStatusId?: number): Promise<void> {
|
||||||
|
const url = moveToStatusId
|
||||||
|
? `/organizations/${orgId}/task-statuses/${id}?moveToStatusId=${moveToStatusId}`
|
||||||
|
: `/organizations/${orgId}/task-statuses/${id}`
|
||||||
|
return apiFetch<void>(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async reorderStatuses(orgId: number, statusIds: number[]): Promise<TaskStatus[]> {
|
||||||
|
return apiFetch<TaskStatus[]>(`/organizations/${orgId}/task-statuses/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: statusIds,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
export interface TaskHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -32,7 +44,8 @@ export interface Task {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status: TaskStatus;
|
status: string;
|
||||||
|
taskStatus?: TaskStatus;
|
||||||
project?: Project;
|
project?: Project;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
user?: TaskUser;
|
user?: TaskUser;
|
||||||
|
|
@ -51,7 +64,8 @@ export interface TaskHistoryItemInput {
|
||||||
export interface CreateTaskPayload {
|
export interface CreateTaskPayload {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: TaskStatus;
|
status?: string;
|
||||||
|
statusId?: number;
|
||||||
organizationId: number;
|
organizationId: number;
|
||||||
projectId?: number;
|
projectId?: number;
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
|
|
@ -67,13 +81,13 @@ export interface ParsedAiTask {
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
assigneeUserId?: number;
|
assigneeUserId?: number;
|
||||||
externalUrl?: string;
|
externalUrl?: string;
|
||||||
status?: TaskStatus;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListTasksFilter {
|
export interface ListTasksFilter {
|
||||||
organizationId?: number;
|
organizationId?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
status?: TaskStatus[];
|
status?: string[];
|
||||||
projectId?: number[];
|
projectId?: number[];
|
||||||
categoryId?: number[];
|
categoryId?: number[];
|
||||||
userId?: number[];
|
userId?: number[];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue