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) {
|
||||
if (this.status == status) return;
|
||||
if (Objects.equals(this.status, status)) return;
|
||||
|
||||
if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) {
|
||||
if (status != null && (status.isCompleted() || status.isCanceled())) {
|
||||
if (isRunning()) {
|
||||
this.stop();
|
||||
}
|
||||
if (status == TaskStatus.COMPLETED) {
|
||||
if (status.isCompleted()) {
|
||||
this.finishedAt = LocalDateTime.now();
|
||||
} else {
|
||||
this.finishedAt = null;
|
||||
|
|
@ -157,7 +157,6 @@ public class Task {
|
|||
throw new IllegalStateException("Tarefa já está em execução");
|
||||
}
|
||||
|
||||
this.status = TaskStatus.IN_PROGRESS;
|
||||
TaskHistoryItem item = new TaskHistoryItem();
|
||||
item.setStartAt(LocalDateTime.now());
|
||||
item.setUser(worker != null ? worker : this.user);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,106 @@
|
|||
package net.beetechgroup.beetask.entities.task;
|
||||
|
||||
public enum TaskStatus {
|
||||
NOT_STARTED("NOT_STARTED"),
|
||||
IN_PROGRESS("IN_PROGRESS"),
|
||||
COMPLETED("COMPLETED"),
|
||||
CANCELED("CANCELED");
|
||||
import java.util.Objects;
|
||||
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||
|
||||
private String status;
|
||||
public class TaskStatus {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String color;
|
||||
private Integer position;
|
||||
private boolean isDefault;
|
||||
private boolean isCompleted;
|
||||
private boolean isCanceled;
|
||||
private Organization organization;
|
||||
|
||||
TaskStatus(String status) {
|
||||
this.status = status;
|
||||
public TaskStatus() {
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
public TaskStatus(Long id, String name, String color, Integer position, boolean isDefault, boolean isCompleted, boolean isCanceled, Organization organization) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.position = position;
|
||||
this.isDefault = isDefault;
|
||||
this.isCompleted = isCompleted;
|
||||
this.isCanceled = isCanceled;
|
||||
this.organization = organization;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public Integer getPosition() {
|
||||
return position != null ? position : 0;
|
||||
}
|
||||
|
||||
public void setPosition(Integer position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
public void setDefault(boolean isDefault) {
|
||||
this.isDefault = isDefault;
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return isCompleted;
|
||||
}
|
||||
|
||||
public void setCompleted(boolean isCompleted) {
|
||||
this.isCompleted = isCompleted;
|
||||
}
|
||||
|
||||
public boolean isCanceled() {
|
||||
return isCanceled;
|
||||
}
|
||||
|
||||
public void setCanceled(boolean isCanceled) {
|
||||
this.isCanceled = isCanceled;
|
||||
}
|
||||
|
||||
public Organization getOrganization() {
|
||||
return organization;
|
||||
}
|
||||
|
||||
public void setOrganization(Organization organization) {
|
||||
this.organization = organization;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
TaskStatus that = (TaskStatus) o;
|
||||
return Objects.equals(id, that.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationA
|
|||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
||||
|
|
@ -33,8 +34,9 @@ public class OrganizationUseCaseConfig {
|
|||
@Produces
|
||||
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
|
||||
UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository) {
|
||||
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository);
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository, taskStatusRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
|
|||
|
|
@ -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.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||
|
|
@ -28,13 +29,15 @@ public class TaskUseCaseConfig {
|
|||
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
return new StartTaskUseCase(taskRepository, userRepository, authorizer);
|
||||
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) {
|
||||
return new StartTaskUseCase(taskRepository, userRepository, authorizer, taskStatusRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
@ -53,16 +56,19 @@ public class TaskUseCaseConfig {
|
|||
}
|
||||
|
||||
@Produces
|
||||
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
return new UpdateTaskStatusUseCase(taskRepository, authorizer);
|
||||
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository,
|
||||
TaskStatusRepository taskStatusRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
return new UpdateTaskStatusUseCase(taskRepository, taskStatusRepository, authorizer);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import jakarta.persistence.OneToMany;
|
|||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.SQLRestriction;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import jakarta.persistence.FetchType;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tasks")
|
||||
|
|
@ -28,8 +28,11 @@ public class TaskEntity {
|
|||
private Long id;
|
||||
private String title;
|
||||
private String description;
|
||||
@Enumerated(EnumType.STRING)
|
||||
private TaskStatus status;
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "status_id")
|
||||
private TaskStatusEntity taskStatus;
|
||||
@Column(name = "status", length = 120)
|
||||
private String status;
|
||||
@Column(name = "finished_at")
|
||||
private java.time.LocalDateTime finishedAt;
|
||||
@Column(name = "external_url", length = 1000)
|
||||
|
|
@ -103,11 +106,19 @@ public class TaskEntity {
|
|||
this.description = description;
|
||||
}
|
||||
|
||||
public TaskStatus getStatus() {
|
||||
public TaskStatusEntity getTaskStatus() {
|
||||
return taskStatus;
|
||||
}
|
||||
|
||||
public void setTaskStatus(TaskStatusEntity taskStatus) {
|
||||
this.taskStatus = taskStatus;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(TaskStatus status) {
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity;
|
||||
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity;
|
||||
|
||||
|
|
@ -14,7 +15,8 @@ public class TaskEntityMapper {
|
|||
entity.setId(task.getId());
|
||||
entity.setTitle(task.getTitle());
|
||||
entity.setDescription(task.getDescription());
|
||||
entity.setStatus(task.getStatus());
|
||||
entity.setTaskStatus(TaskStatusEntityMapper.toEntity(task.getStatus()));
|
||||
entity.setStatus(task.getStatus() != null ? task.getStatus().getName() : null);
|
||||
entity.setFinishedAt(task.getFinishedAt());
|
||||
entity.setExternalUrl(task.getExternalUrl());
|
||||
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
|
||||
|
|
@ -37,7 +39,13 @@ public class TaskEntityMapper {
|
|||
task.setId(entity.getId());
|
||||
task.setTitle(entity.getTitle());
|
||||
task.setDescription(entity.getDescription());
|
||||
task.setStatus(entity.getStatus());
|
||||
if (entity.getTaskStatus() != null) {
|
||||
task.setStatus(TaskStatusEntityMapper.toDomain(entity.getTaskStatus()));
|
||||
} else if (entity.getStatus() != null) {
|
||||
TaskStatus fallback = new TaskStatus();
|
||||
fallback.setName(entity.getStatus());
|
||||
task.setStatus(fallback);
|
||||
}
|
||||
task.setFinishedAt(entity.getFinishedAt());
|
||||
task.setExternalUrl(entity.getExternalUrl());
|
||||
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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(
|
||||
"select distinct t from TaskEntity t left join t.project p left join t.category c " +
|
||||
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)"
|
||||
|
|
@ -77,7 +77,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
idx++;
|
||||
}
|
||||
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);
|
||||
idx++;
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
|
||||
@Override
|
||||
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))");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(email);
|
||||
|
|
@ -118,7 +118,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
idx++;
|
||||
}
|
||||
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);
|
||||
idx++;
|
||||
}
|
||||
|
|
@ -167,8 +167,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
|
||||
@Override
|
||||
public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
||||
return find("user.email = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
||||
email, TaskStatus.COMPLETED, start, end)
|
||||
return find("user.email = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||
email, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
|
|
@ -193,8 +193,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
return find("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) " +
|
||||
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " +
|
||||
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||
"and t.finishedAt is not null and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
|
|
@ -225,8 +224,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
|
||||
@Override
|
||||
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
|
||||
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
||||
userId, TaskStatus.COMPLETED, start, end)
|
||||
return find("user.id = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||
userId, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
|
|
@ -235,8 +234,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
return find("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) " +
|
||||
"and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
||||
orgId, userId, TaskStatus.COMPLETED, start, end)
|
||||
"and t.user.id = ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
|
||||
orgId, userId, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
|
|
@ -279,8 +278,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
|
||||
return List.of();
|
||||
}
|
||||
return find("user.email in ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
||||
emails, TaskStatus.COMPLETED, start, end)
|
||||
return find("user.email in ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
|
||||
emails, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
|
|
@ -293,14 +292,14 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
return find("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) " +
|
||||
"and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
||||
orgId, emails, TaskStatus.COMPLETED, start, end)
|
||||
"and t.user.email in ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
|
||||
orgId, emails, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countByStatus(TaskStatus status) {
|
||||
return count("status", status);
|
||||
public long countCompletedTasks() {
|
||||
return count("finishedAt is not null or (taskStatus is not null and taskStatus.isCompleted = true)");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -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 net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record CreateTaskRequest(
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
String status,
|
||||
Long statusId,
|
||||
Long organizationId,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record CreateTaskResponse(
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
String status,
|
||||
TaskStatusResponse taskStatus,
|
||||
ProjectResponse project,
|
||||
CategoryResponse category,
|
||||
UserResponse user,
|
||||
|
|
@ -18,8 +17,16 @@ public record CreateTaskResponse(
|
|||
List<TaskHistoryItemResponse> history,
|
||||
String externalUrl) {
|
||||
|
||||
public record TaskStatusResponse(
|
||||
Long id,
|
||||
String name,
|
||||
String color,
|
||||
Integer position,
|
||||
boolean isDefault,
|
||||
boolean isCompleted,
|
||||
boolean isCanceled) {}
|
||||
|
||||
public record ProjectResponse(Long id, String name) {}
|
||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||
public record UserResponse(Long id, String name, String email, String photo) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public class ListMyTasksRequest {
|
||||
|
||||
|
|
@ -14,7 +12,7 @@ public class ListMyTasksRequest {
|
|||
public List<Long> projectIds;
|
||||
|
||||
@QueryParam("status")
|
||||
public List<TaskStatus> statuses;
|
||||
public List<String> statuses;
|
||||
|
||||
@QueryParam("categoryId")
|
||||
public List<Long> categoryIds;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public class ListTasksRequest {
|
||||
|
||||
|
|
@ -17,7 +15,7 @@ public class ListTasksRequest {
|
|||
public List<Long> projectIds;
|
||||
|
||||
@QueryParam("status")
|
||||
public List<TaskStatus> statuses;
|
||||
public List<String> statuses;
|
||||
|
||||
@QueryParam("categoryId")
|
||||
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.jboss.logging.Logger;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||
|
|
@ -145,15 +144,41 @@ public class TaskController {
|
|||
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/status")
|
||||
@Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
|
||||
@Operation(summary = "Update task status (PUT)", description = "Updates the status of a task")
|
||||
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
||||
public CreateTaskResponse updateTaskStatusPut(@PathParam("id") Long id, String rawStatus) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOGGER.infof("Status update (PUT) requested for task %d to %s by %s", id, rawStatus, email);
|
||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email);
|
||||
LOGGER.infof("Task %d status updated successfully", id);
|
||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}/status")
|
||||
@Operation(summary = "Update task status", description = "Updates the status of a task")
|
||||
@Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
|
||||
@Operation(summary = "Update task status (PATCH)", description = "Updates the status of a task")
|
||||
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
||||
public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) {
|
||||
public CreateTaskResponse updateTaskStatusPatch(@PathParam("id") Long id, String rawStatus) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email);
|
||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email);
|
||||
LOGGER.infof("Task %d status updated successfully to %s", id, status);
|
||||
LOGGER.infof("Status update (PATCH) requested for task %d to %s by %s", id, rawStatus, email);
|
||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email);
|
||||
LOGGER.infof("Task %d status updated successfully", id);
|
||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/status/{statusId}")
|
||||
@Operation(summary = "Update task status by ID", description = "Updates the status of a task by status ID")
|
||||
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
||||
public CreateTaskResponse updateTaskStatusById(@PathParam("id") Long id, @PathParam("statusId") Long statusId) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
LOGGER.infof("Status update by ID requested for task %d to status %d by %s", id, statusId, email);
|
||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, statusId, null, email);
|
||||
LOGGER.infof("Task %d status updated successfully to status %d", id, statusId);
|
||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||
|
|
@ -12,20 +14,42 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
|
|||
|
||||
public class TaskControllerMapper {
|
||||
|
||||
private static List<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) {
|
||||
return new ListTasksInput(
|
||||
email,
|
||||
request.organizationId,
|
||||
request.text,
|
||||
request.projectIds,
|
||||
request.statuses,
|
||||
normalizeStatuses(request.statuses),
|
||||
request.categoryIds,
|
||||
request.userIds
|
||||
);
|
||||
}
|
||||
|
||||
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
|
||||
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
|
||||
return new ListMyTasksInput(email, request.text, request.projectIds, normalizeStatuses(request.statuses), request.categoryIds);
|
||||
}
|
||||
|
||||
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
|
||||
|
|
@ -33,6 +57,7 @@ public class TaskControllerMapper {
|
|||
request.title(),
|
||||
request.description(),
|
||||
request.status(),
|
||||
request.statusId(),
|
||||
request.organizationId(),
|
||||
request.projectId(),
|
||||
request.categoryId(),
|
||||
|
|
@ -50,7 +75,15 @@ public class TaskControllerMapper {
|
|||
output.id(),
|
||||
output.title(),
|
||||
output.description(),
|
||||
output.status(),
|
||||
output.status() != null ? output.status().getName() : null,
|
||||
output.status() != null ? new CreateTaskResponse.TaskStatusResponse(
|
||||
output.status().getId(),
|
||||
output.status().getName(),
|
||||
output.status().getColor(),
|
||||
output.status().getPosition(),
|
||||
output.status().isDefault(),
|
||||
output.status().isCompleted(),
|
||||
output.status().isCanceled()) : null,
|
||||
Objects.nonNull(output.project())
|
||||
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
|
||||
: null,
|
||||
|
|
@ -96,6 +129,7 @@ public class TaskControllerMapper {
|
|||
request.title(),
|
||||
request.description(),
|
||||
request.status(),
|
||||
request.statusId(),
|
||||
request.organizationId(),
|
||||
request.projectId(),
|
||||
request.categoryId(),
|
||||
|
|
|
|||
|
|
@ -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.UserOrganizationRole;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
||||
|
|
@ -17,13 +19,16 @@ public class CreateOrganizationUseCase {
|
|||
private final OrganizationRepository organizationRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserOrganizationRepository userOrganizationRepository;
|
||||
private final TaskStatusRepository taskStatusRepository;
|
||||
|
||||
public CreateOrganizationUseCase(OrganizationRepository organizationRepository,
|
||||
UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository) {
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
this.organizationRepository = organizationRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userOrganizationRepository = userOrganizationRepository;
|
||||
this.taskStatusRepository = taskStatusRepository;
|
||||
}
|
||||
|
||||
public CreateOrganizationOutput execute(CreateOrganizationInput input) {
|
||||
|
|
@ -59,6 +64,17 @@ public class CreateOrganizationUseCase {
|
|||
userOrganizationRepository.save(userOrganization);
|
||||
LOGGER.infof("Organization %d created successfully with owner user %d", organization.getId(), user.getId());
|
||||
|
||||
// Create 4 default task statuses
|
||||
createDefaultTaskStatuses(organization);
|
||||
|
||||
return CreateOrganizationMapper.toCreateOrganizationOutput(organization);
|
||||
}
|
||||
|
||||
private void createDefaultTaskStatuses(Organization org) {
|
||||
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "A Fazer", "#71717a", 0, true, false, false, org));
|
||||
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Em Andamento", "#14b8a6", 1, false, false, false, org));
|
||||
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Concluído", "#10b981", 2, false, true, false, org));
|
||||
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Cancelado", "#f43f5e", 3, false, false, true, org));
|
||||
LOGGER.infof("Created 4 default task statuses for organization %d", org.getId());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package net.beetechgroup.beetask.usecase.publicstats;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
|
@ -19,7 +18,7 @@ public class PublicStatsUseCase {
|
|||
public PublicStatsOutput execute() {
|
||||
long totalUsers = userRepository.countAll();
|
||||
long totalOrganizations = organizationRepository.countAll();
|
||||
long totalCompletedTasks = taskRepository.countByStatus(TaskStatus.COMPLETED);
|
||||
long totalCompletedTasks = taskRepository.countCompletedTasks();
|
||||
double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0;
|
||||
|
||||
return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours);
|
||||
|
|
|
|||
|
|
@ -423,7 +423,14 @@ public class GenerateReportUseCase {
|
|||
}
|
||||
}
|
||||
if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) {
|
||||
if (t.getStatus() == null || !t.getStatus().name().equalsIgnoreCase(section.getFilterStatus())) {
|
||||
String statusName = t.getStatus() != null ? t.getStatus().getName() : "";
|
||||
String filter = section.getFilterStatus();
|
||||
boolean match = statusName.equalsIgnoreCase(filter)
|
||||
|| ("NOT_STARTED".equalsIgnoreCase(filter) && "A Fazer".equalsIgnoreCase(statusName))
|
||||
|| ("IN_PROGRESS".equalsIgnoreCase(filter) && "Em Andamento".equalsIgnoreCase(statusName))
|
||||
|| ("COMPLETED".equalsIgnoreCase(filter) && "Concluído".equalsIgnoreCase(statusName))
|
||||
|| ("CANCELED".equalsIgnoreCase(filter) && "Cancelado".equalsIgnoreCase(statusName));
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -457,13 +464,8 @@ public class GenerateReportUseCase {
|
|||
}
|
||||
|
||||
private String formatStatus(TaskStatus status) {
|
||||
if (status == null) return "";
|
||||
return switch (status) {
|
||||
case COMPLETED -> "Concluído";
|
||||
case IN_PROGRESS -> "Em Andamento";
|
||||
case NOT_STARTED -> "A Fazer";
|
||||
case CANCELED -> "Cancelado";
|
||||
};
|
||||
if (status == null || status.getName() == null) return "";
|
||||
return status.getName();
|
||||
}
|
||||
|
||||
private String capitalize(String text) {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ public interface TaskRepository {
|
|||
Task findTaskById(Long id);
|
||||
List<Task> findAllTasks();
|
||||
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> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
|
||||
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksByUser(String email);
|
||||
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> findTasksFinishedByOrgInPeriod(Long orgId, 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> findTasksFinishedByUserEmailsInPeriod(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();
|
||||
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;
|
||||
|
||||
import java.util.List;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
|
||||
|
||||
public record CreateTaskInput(
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
String status,
|
||||
Long statusId,
|
||||
Long organizationId,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import net.beetechgroup.beetask.entities.User;
|
|||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
|
|
@ -24,17 +26,20 @@ public class CreateTaskUseCase {
|
|||
private final UserRepository userRepository;
|
||||
private final UserOrganizationRepository userOrganizationRepository;
|
||||
private final OrganizationRepository organizationRepository;
|
||||
private final TaskStatusRepository taskStatusRepository;
|
||||
|
||||
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
OrganizationRepository organizationRepository) {
|
||||
OrganizationRepository organizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userOrganizationRepository = userOrganizationRepository;
|
||||
this.organizationRepository = organizationRepository;
|
||||
this.taskStatusRepository = taskStatusRepository;
|
||||
}
|
||||
|
||||
public CreateTaskOutput execute(CreateTaskInput input) {
|
||||
|
|
@ -42,7 +47,6 @@ public class CreateTaskUseCase {
|
|||
Task task = new Task();
|
||||
task.setTitle(input.title());
|
||||
task.setDescription(input.description());
|
||||
task.setStatus(input.status());
|
||||
task.setExternalUrl(input.externalUrl());
|
||||
|
||||
if (Objects.nonNull(input.projectId())) {
|
||||
|
|
@ -72,6 +76,28 @@ public class CreateTaskUseCase {
|
|||
task.setOrganization(task.getCategory().getOrganization());
|
||||
}
|
||||
|
||||
// Resolve task status
|
||||
TaskStatus taskStatus = null;
|
||||
if (input.statusId() != null) {
|
||||
taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null);
|
||||
}
|
||||
if (taskStatus == null && input.status() != null && !input.status().isBlank() && task.getOrganization() != null) {
|
||||
String name = input.status().trim();
|
||||
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
|
||||
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
|
||||
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
|
||||
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
|
||||
taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, task.getOrganization().getId()).orElse(null);
|
||||
}
|
||||
if (taskStatus == null && task.getOrganization() != null) {
|
||||
taskStatus = taskStatusRepository.findDefaultByOrganizationId(task.getOrganization().getId())
|
||||
.orElseGet(() -> {
|
||||
List<TaskStatus> all = taskStatusRepository.findByOrganizationId(task.getOrganization().getId());
|
||||
return all.isEmpty() ? null : all.get(0);
|
||||
});
|
||||
}
|
||||
task.setStatus(taskStatus);
|
||||
|
||||
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
||||
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
|
||||
task.setCreatedBy(creator);
|
||||
|
|
@ -87,7 +113,8 @@ public class CreateTaskUseCase {
|
|||
}
|
||||
|
||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||
LOGGER.infof("Task %d created for user %s", output.id(), input.userEmail());
|
||||
LOGGER.infof("Task %d created for user %s with status %s", output.id(), input.userEmail(),
|
||||
task.getStatus() != null ? task.getStatus().getName() : "none");
|
||||
return output;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,5 @@ package net.beetechgroup.beetask.usecase.task.listall;
|
|||
|
||||
import java.util.List;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record ListMyTasksInput(String email, String text, List<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 net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record ListTasksInput(
|
||||
String userEmail,
|
||||
Long organizationId,
|
||||
String text,
|
||||
List<Long> projectIds,
|
||||
List<TaskStatus> statuses,
|
||||
List<String> statuses,
|
||||
List<Long> categoryIds,
|
||||
List<Long> userIds) {}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package net.beetechgroup.beetask.usecase.task.start;
|
|||
|
||||
import java.util.Objects;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||
|
|
@ -15,11 +17,14 @@ public class StartTaskUseCase {
|
|||
private final TaskRepository taskRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||
private final TaskStatusRepository taskStatusRepository;
|
||||
|
||||
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.authorizer = authorizer;
|
||||
this.taskStatusRepository = taskStatusRepository;
|
||||
}
|
||||
|
||||
public CreateTaskOutput execute(StartTaskInput input) {
|
||||
|
|
@ -57,10 +62,15 @@ public class StartTaskUseCase {
|
|||
}
|
||||
}
|
||||
|
||||
// If task is not started or in default status, move to "Em Andamento"
|
||||
if (orgId != null && (task.getStatus() == null || task.getStatus().isDefault() || "A Fazer".equalsIgnoreCase(task.getStatus().getName()))) {
|
||||
taskStatusRepository.findByNameAndOrganizationId("Em Andamento", orgId)
|
||||
.ifPresent(task::setStatus);
|
||||
}
|
||||
|
||||
task.start(currentUser);
|
||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||
LOGGER.infof("Task %d started successfully", input.id());
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
package net.beetechgroup.beetask.usecase.task.update;
|
||||
|
||||
import java.util.List;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record UpdateTaskInput(
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
String status,
|
||||
Long statusId,
|
||||
Long organizationId,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import net.beetechgroup.beetask.entities.task.Task;
|
|||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||
import org.jboss.logging.Logger;
|
||||
|
|
@ -12,19 +13,25 @@ public class UpdateTaskStatusUseCase {
|
|||
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
|
||||
|
||||
private final TaskRepository taskRepository;
|
||||
private final TaskStatusRepository taskStatusRepository;
|
||||
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||
|
||||
public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
public UpdateTaskStatusUseCase(TaskRepository taskRepository,
|
||||
TaskStatusRepository taskStatusRepository,
|
||||
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.taskStatusRepository = taskStatusRepository;
|
||||
this.authorizer = authorizer;
|
||||
}
|
||||
|
||||
public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) {
|
||||
LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail);
|
||||
public CreateTaskOutput execute(Long id, Long newStatusId, String newStatusName, String userEmail) {
|
||||
LOGGER.infof("Updating status for task %d (statusId=%s, statusName=%s) by user %s", id, newStatusId, newStatusName, userEmail);
|
||||
Task task = taskRepository.findTaskById(id);
|
||||
|
||||
Long orgId = null;
|
||||
if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||
if (task.getOrganization() != null) {
|
||||
orgId = task.getOrganization().getId();
|
||||
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||
orgId = task.getProject().getOrganization().getId();
|
||||
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||
orgId = task.getCategory().getOrganization().getId();
|
||||
|
|
@ -37,11 +44,45 @@ public class UpdateTaskStatusUseCase {
|
|||
throw new SecurityException("Você não tem permissão para atualizar esta tarefa.");
|
||||
}
|
||||
|
||||
task.setStatus(newStatus);
|
||||
TaskStatus resolved = null;
|
||||
if (newStatusId != null) {
|
||||
resolved = taskStatusRepository.findTaskStatusById(newStatusId).orElse(null);
|
||||
}
|
||||
if (resolved == null && newStatusName != null && !newStatusName.isBlank() && orgId != null) {
|
||||
String name = newStatusName.trim();
|
||||
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
|
||||
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
|
||||
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
|
||||
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
|
||||
resolved = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null);
|
||||
}
|
||||
|
||||
if (resolved == null) {
|
||||
throw new IllegalArgumentException("Status não encontrado para a tarefa.");
|
||||
}
|
||||
|
||||
task.setStatus(resolved);
|
||||
|
||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||
LOGGER.infof("Status updated for task %d to %s", id, newStatus);
|
||||
LOGGER.infof("Status updated for task %d to %s", id, resolved.getName());
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
public CreateTaskOutput execute(Long id, String rawStatus, String userEmail) {
|
||||
if (rawStatus == null || rawStatus.isBlank()) {
|
||||
throw new IllegalArgumentException("Status é obrigatório.");
|
||||
}
|
||||
String clean = rawStatus.trim();
|
||||
if (clean.startsWith("\"") && clean.endsWith("\"") && clean.length() >= 2) {
|
||||
clean = clean.substring(1, clean.length() - 1).trim();
|
||||
}
|
||||
|
||||
Long statusId = null;
|
||||
try {
|
||||
statusId = Long.parseLong(clean);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
return execute(id, statusId, clean, userEmail);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import net.beetechgroup.beetask.entities.User;
|
|||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||
|
|
@ -25,17 +27,20 @@ public class UpdateTaskUseCase {
|
|||
private final UserRepository userRepository;
|
||||
private final UserOrganizationRepository userOrganizationRepository;
|
||||
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
|
||||
private final TaskStatusRepository taskStatusRepository;
|
||||
|
||||
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||
UserOrganizationRepository userOrganizationRepository,
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
|
||||
TaskStatusRepository taskStatusRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userOrganizationRepository = userOrganizationRepository;
|
||||
this.organizationRepository = organizationRepository;
|
||||
this.taskStatusRepository = taskStatusRepository;
|
||||
}
|
||||
|
||||
public CreateTaskOutput execute(UpdateTaskInput input) {
|
||||
|
|
@ -58,9 +63,28 @@ public class UpdateTaskUseCase {
|
|||
|
||||
task.setTitle(input.title());
|
||||
task.setDescription(input.description());
|
||||
task.setStatus(input.status());
|
||||
task.setExternalUrl(input.externalUrl());
|
||||
|
||||
if (input.statusId() != null || (input.status() != null && !input.status().isBlank())) {
|
||||
TaskStatus taskStatus = null;
|
||||
if (input.statusId() != null) {
|
||||
taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null);
|
||||
}
|
||||
Long orgId = input.organizationId() != null ? input.organizationId() :
|
||||
(task.getOrganization() != null ? task.getOrganization().getId() : null);
|
||||
if (taskStatus == null && input.status() != null && !input.status().isBlank() && orgId != null) {
|
||||
String name = input.status().trim();
|
||||
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
|
||||
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
|
||||
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
|
||||
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
|
||||
taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null);
|
||||
}
|
||||
if (taskStatus != null) {
|
||||
task.setStatus(taskStatus);
|
||||
}
|
||||
}
|
||||
|
||||
if (Objects.nonNull(input.organizationId())) {
|
||||
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { ProjectsPage } from './pages/ProjectsPage'
|
||||
import { CategoriesPage } from './pages/CategoriesPage'
|
||||
import { StatusesPage } from './pages/StatusesPage'
|
||||
import { MembersPage } from './pages/MembersPage'
|
||||
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage'
|
||||
|
|
@ -45,6 +46,7 @@ export function App() {
|
|||
<Route path="/kanban" element={<TasksPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/statuses" element={<StatusesPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/members" element={<MembersPage />} />
|
||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
LogOut,
|
||||
FileText,
|
||||
Lock,
|
||||
CircleDot,
|
||||
} from 'lucide-react'
|
||||
import { Chip, ChipLabel } from '@heroui/react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
|
@ -95,6 +96,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
links: [
|
||||
{ to: '/projects', label: 'Projetos', icon: <FolderKanban 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 { organizationService } from '../../services/organizationService'
|
||||
import { taskService } from '../../services/taskService'
|
||||
import { taskStatusService } from '../../services/taskStatusService'
|
||||
import { CustomSelect } from '../ui/Select'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||
|
|
@ -42,7 +43,8 @@ interface CreateTaskModalProps {
|
|||
onSubmit: (taskData: {
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
status: string
|
||||
statusId?: number
|
||||
projectId?: number
|
||||
categoryId?: number
|
||||
assigneeUserId?: number
|
||||
|
|
@ -51,10 +53,11 @@ interface CreateTaskModalProps {
|
|||
}) => Promise<void>
|
||||
onDelete?: (task: Task) => void
|
||||
organizationId: number
|
||||
initialStatus?: TaskStatus
|
||||
initialStatus?: string
|
||||
initialTask?: Task | null
|
||||
initialTab?: 'details' | 'history'
|
||||
initialDraft?: Partial<ParsedAiTask> | null
|
||||
taskStatuses?: TaskStatus[]
|
||||
}
|
||||
|
||||
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
|
|
@ -63,10 +66,11 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
onSubmit,
|
||||
onDelete,
|
||||
organizationId,
|
||||
initialStatus = 'NOT_STARTED',
|
||||
initialStatus = 'A Fazer',
|
||||
initialTask = null,
|
||||
initialTab = 'details',
|
||||
initialDraft = null,
|
||||
taskStatuses: propTaskStatuses,
|
||||
}) => {
|
||||
const { user, activeOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
||||
|
|
@ -74,7 +78,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
// Task details
|
||||
const [title, setTitle] = 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 [categoryId, setCategoryId] = 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 [categories, setCategories] = useState<Category[]>([])
|
||||
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>(propTaskStatuses || [])
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([])
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -214,14 +219,18 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
const loadMetadata = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [projRes, catRes, memRes] = await Promise.allSettled([
|
||||
const [projRes, catRes, memRes, statRes] = await Promise.allSettled([
|
||||
projectService.listProjects(organizationId),
|
||||
categoryService.listCategories(organizationId),
|
||||
organizationService.listMembers(organizationId),
|
||||
propTaskStatuses && propTaskStatuses.length > 0
|
||||
? Promise.resolve(propTaskStatuses)
|
||||
: taskStatusService.listStatuses(organizationId),
|
||||
])
|
||||
|
||||
if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
|
||||
if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
|
||||
if (statRes.status === 'fulfilled') setTaskStatuses(statRes.value || [])
|
||||
if (memRes.status === 'fulfilled') {
|
||||
const memberList = memRes.value || []
|
||||
setMembers(memberList)
|
||||
|
|
@ -427,10 +436,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
effectiveAssigneeUserId = undefined
|
||||
}
|
||||
|
||||
const matchedStatus = taskStatuses.find((s) => s.name === status)
|
||||
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
status,
|
||||
statusId: matchedStatus?.id,
|
||||
projectId,
|
||||
categoryId,
|
||||
assigneeUserId: effectiveAssigneeUserId,
|
||||
|
|
@ -769,13 +781,23 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
</label>
|
||||
<CustomSelect
|
||||
value={status}
|
||||
onChange={(val) => setStatus(val as TaskStatus)}
|
||||
options={[
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
]}
|
||||
onChange={(val) => setStatus(val)}
|
||||
options={
|
||||
taskStatuses.length > 0
|
||||
? [...taskStatuses]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name,
|
||||
color: s.color || '#71717a',
|
||||
}))
|
||||
: [
|
||||
{ value: 'A Fazer', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'Concluído', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,63 +1,74 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical } from 'lucide-react'
|
||||
import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical, Edit2, Trash2 } from 'lucide-react'
|
||||
import type { Task, TaskStatus } from '../../types/task'
|
||||
import { TaskCard } from './TaskCard'
|
||||
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
|
||||
|
||||
interface TaskKanbanViewProps {
|
||||
tasks: Task[]
|
||||
taskStatuses?: TaskStatus[]
|
||||
onToggleTimer: (task: Task) => void
|
||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||
onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
|
||||
onEditTask: (task: Task) => void
|
||||
onOpenHistory?: (task: Task) => void
|
||||
onDeleteTask?: (task: Task) => void
|
||||
onAddTaskToStatus: (status: TaskStatus) => void
|
||||
onAddTaskToStatus: (status: string, statusId?: number) => void
|
||||
onOpenCreateStatus?: () => void
|
||||
onEditStatus?: (status: TaskStatus) => void
|
||||
onDeleteStatus?: (status: TaskStatus) => void
|
||||
activeTaskId?: number | null
|
||||
activeTaskSeconds?: number
|
||||
hiddenColumns?: TaskStatus[]
|
||||
hiddenColumns?: string[]
|
||||
showAllColumns?: boolean
|
||||
onHideColumn?: (status: TaskStatus) => void
|
||||
onUnhideColumn?: (status: TaskStatus) => void
|
||||
onHideColumn?: (status: string) => void
|
||||
onUnhideColumn?: (status: string) => void
|
||||
}
|
||||
|
||||
export interface ColumnDef {
|
||||
status: TaskStatus
|
||||
id?: number
|
||||
status: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
colorClass: string
|
||||
bgLightClass: string
|
||||
borderColor: string
|
||||
color: string
|
||||
colorClass?: string
|
||||
bgLightClass?: string
|
||||
borderColor?: string
|
||||
rawStatus?: TaskStatus
|
||||
}
|
||||
|
||||
export const COLUMNS: ColumnDef[] = [
|
||||
export const DEFAULT_COLUMNS: ColumnDef[] = [
|
||||
{
|
||||
status: 'NOT_STARTED',
|
||||
status: 'A Fazer',
|
||||
label: 'A Fazer',
|
||||
icon: CircleDot,
|
||||
color: '#71717a',
|
||||
colorClass: 'text-muted',
|
||||
bgLightClass: 'bg-surface-sunken',
|
||||
borderColor: 'border-border-soft',
|
||||
},
|
||||
{
|
||||
status: 'IN_PROGRESS',
|
||||
status: 'Em Andamento',
|
||||
label: 'Em Andamento',
|
||||
icon: Clock,
|
||||
color: '#14b8a6',
|
||||
colorClass: 'text-teal-700 dark:text-teal-400',
|
||||
bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15',
|
||||
borderColor: 'border-teal-600/25 dark:border-teal-500/20',
|
||||
},
|
||||
{
|
||||
status: 'COMPLETED',
|
||||
status: 'Concluído',
|
||||
label: 'Concluído',
|
||||
icon: CheckCircle2,
|
||||
color: '#10b981',
|
||||
colorClass: 'text-emerald-700 dark:text-emerald-400',
|
||||
bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15',
|
||||
borderColor: 'border-emerald-600/25 dark:border-emerald-500/20',
|
||||
},
|
||||
{
|
||||
status: 'CANCELED',
|
||||
status: 'Cancelado',
|
||||
label: 'Cancelado',
|
||||
icon: Ban,
|
||||
color: '#f43f5e',
|
||||
colorClass: 'text-rose-700 dark:text-rose-400',
|
||||
bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15',
|
||||
borderColor: 'border-rose-600/25 dark:border-rose-500/20',
|
||||
|
|
@ -66,12 +77,16 @@ export const COLUMNS: ColumnDef[] = [
|
|||
|
||||
export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||
tasks,
|
||||
taskStatuses = [],
|
||||
onToggleTimer,
|
||||
onStatusChange,
|
||||
onEditTask,
|
||||
onOpenHistory,
|
||||
onDeleteTask,
|
||||
onAddTaskToStatus,
|
||||
onOpenCreateStatus,
|
||||
onEditStatus,
|
||||
onDeleteStatus,
|
||||
activeTaskId,
|
||||
activeTaskSeconds,
|
||||
hiddenColumns = [],
|
||||
|
|
@ -79,20 +94,44 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
onHideColumn,
|
||||
onUnhideColumn,
|
||||
}) => {
|
||||
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null)
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState<TaskStatus | null>(null)
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | 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
|
||||
const visibleColumns = COLUMNS.filter((col) => {
|
||||
const visibleColumns = columns.filter((col) => {
|
||||
if (showAllColumns) return true
|
||||
return !hiddenColumns.includes(col.status)
|
||||
return !hiddenColumns.includes(col.status) && (!col.id || !hiddenColumns.includes(String(col.id)))
|
||||
})
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, status: TaskStatus) => {
|
||||
const handleDragOver = (e: React.DragEvent, statusKey: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverColumn !== status) {
|
||||
setDragOverColumn(status)
|
||||
if (dragOverColumn !== statusKey) {
|
||||
setDragOverColumn(statusKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +140,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
setDragOverColumn(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => {
|
||||
const handleDrop = (e: React.DragEvent, targetCol: ColumnDef) => {
|
||||
e.preventDefault()
|
||||
setDragOverColumn(null)
|
||||
const taskIdStr = e.dataTransfer.getData('text/plain')
|
||||
|
|
@ -109,16 +148,15 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
|
||||
const taskId = Number(taskIdStr)
|
||||
const targetTask = tasks.find((t) => t.id === taskId)
|
||||
if (targetTask && targetTask.status !== targetStatus) {
|
||||
onStatusChange(targetTask, targetStatus)
|
||||
}
|
||||
}
|
||||
if (!targetTask) return
|
||||
|
||||
const getGridColsClass = (count: number) => {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 md:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
|
||||
const isSameStatus =
|
||||
(targetCol.id && targetTask.taskStatus?.id === targetCol.id) ||
|
||||
targetTask.status === targetCol.status
|
||||
|
||||
if (!isSameStatus) {
|
||||
onStatusChange(targetTask, targetCol.status, targetCol.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleColumns.length === 0) {
|
||||
|
|
@ -133,11 +171,42 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
const isWideBoard = visibleColumns.length > 4
|
||||
|
||||
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) => {
|
||||
const columnTasks = tasks.filter((t) => t.status === col.status)
|
||||
const isHidden = hiddenColumns.includes(col.status)
|
||||
const columnTasks = tasks.filter((t) => {
|
||||
if (col.id && t.taskStatus?.id) {
|
||||
return t.taskStatus.id === col.id
|
||||
}
|
||||
if (col.id && t.status === String(col.id)) {
|
||||
return true
|
||||
}
|
||||
const tStatus = t.status || ''
|
||||
if (tStatus === col.status || tStatus === col.label) return true
|
||||
if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) {
|
||||
return true
|
||||
}
|
||||
if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) {
|
||||
return true
|
||||
}
|
||||
if ((col.status === 'Concluído' || col.status === 'COMPLETED') && (tStatus === 'COMPLETED' || tStatus === 'Concluído' || t.taskStatus?.isCompleted)) {
|
||||
return true
|
||||
}
|
||||
if ((col.status === 'Cancelado' || col.status === 'CANCELED') && (tStatus === 'CANCELED' || tStatus === 'Cancelado' || t.taskStatus?.isCanceled)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const isHidden = hiddenColumns.includes(col.status) || (col.id ? hiddenColumns.includes(String(col.id)) : false)
|
||||
const colTotalSeconds = columnTasks.reduce(
|
||||
(acc, t) =>
|
||||
acc +
|
||||
|
|
@ -152,23 +221,28 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={col.status}
|
||||
key={col.id ? `col-${col.id}` : `col-${col.status}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.status)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, col.status)}
|
||||
onDrop={(e) => handleDrop(e, col)}
|
||||
style={{
|
||||
borderColor: isOver ? col.color : `${col.color}35`,
|
||||
}}
|
||||
className={`flex flex-col rounded-3xl border transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] overflow-hidden bg-surface/60 backdrop-blur-md shadow-xs ${
|
||||
isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''
|
||||
} ${
|
||||
isOver
|
||||
? 'border-brand ring-2 ring-brand/30 bg-brand/5'
|
||||
: col.borderColor
|
||||
isWideBoard ? 'w-80 shrink-0' : 'w-full'
|
||||
} ${isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''} ${
|
||||
isOver ? 'ring-2 ring-brand/30 bg-brand/5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<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={`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" />
|
||||
</div>
|
||||
|
|
@ -195,14 +269,14 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddTaskToStatus(col.status)}
|
||||
onClick={() => onAddTaskToStatus(col.status, col.id)}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Column options menu (hide/unhide) */}
|
||||
{/* Column options menu */}
|
||||
<div className="relative">
|
||||
<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">
|
||||
{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 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -247,12 +336,27 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
onHideColumn?.(col.status)
|
||||
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" />
|
||||
<span>Ocultar coluna</span>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -281,7 +385,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
|
|
@ -293,6 +397,23 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import { canDeleteTask } from '../../lib/taskPermissions'
|
|||
|
||||
interface TaskListViewProps {
|
||||
tasks: Task[]
|
||||
taskStatuses?: TaskStatus[]
|
||||
onToggleTimer: (task: Task) => void
|
||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||
onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
|
||||
onEditTask: (task: Task) => void
|
||||
onOpenHistory?: (task: Task) => void
|
||||
onDeleteTask?: (task: Task) => void
|
||||
|
|
@ -20,6 +21,7 @@ interface TaskListViewProps {
|
|||
|
||||
export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||
tasks,
|
||||
taskStatuses = [],
|
||||
onToggleTimer,
|
||||
onStatusChange,
|
||||
onEditTask,
|
||||
|
|
@ -29,6 +31,24 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
activeTaskSeconds,
|
||||
}) => {
|
||||
const { user, activeOrg } = useAuth()
|
||||
|
||||
const statusOptions = React.useMemo(() => {
|
||||
if (taskStatuses && taskStatuses.length > 0) {
|
||||
return [...taskStatuses]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name,
|
||||
color: s.color || '#71717a',
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{ value: 'A Fazer', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'Concluído', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' },
|
||||
]
|
||||
}, [taskStatuses])
|
||||
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
|
||||
|
||||
return (
|
||||
|
|
@ -151,14 +171,12 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
<div className="w-36">
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={task.status}
|
||||
onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)}
|
||||
options={[
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
]}
|
||||
value={task.taskStatus?.name || task.status}
|
||||
onChange={(newStatus) => {
|
||||
const matched = taskStatuses.find((s) => s.name === newStatus)
|
||||
onStatusChange(task, newStatus, matched?.id)
|
||||
}}
|
||||
options={statusOptions}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { authService } from '../services/authService'
|
||||
import type { TaskStatus } from '../types/task'
|
||||
|
||||
export type DashboardWidgetId =
|
||||
| 'kpis'
|
||||
|
|
@ -110,7 +109,7 @@ export interface UserPreferences {
|
|||
targetHours: number
|
||||
}
|
||||
kanban?: {
|
||||
hiddenColumns?: TaskStatus[]
|
||||
hiddenColumns?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,8 @@ export const DashboardPage: React.FC = () => {
|
|||
const handleModalSubmit = async (taskData: {
|
||||
title: string
|
||||
description: string
|
||||
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'
|
||||
status: string
|
||||
statusId?: number
|
||||
projectId?: number
|
||||
categoryId?: number
|
||||
assigneeUserId?: number
|
||||
|
|
@ -345,6 +346,7 @@ export const DashboardPage: React.FC = () => {
|
|||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
statusId: taskData.statusId,
|
||||
projectId: taskData.projectId,
|
||||
categoryId: taskData.categoryId,
|
||||
assigneeUserId: taskData.assigneeUserId,
|
||||
|
|
|
|||
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,
|
||||
Folder,
|
||||
Users,
|
||||
User,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
CircleDot,
|
||||
Ban,
|
||||
Eye,
|
||||
EyeOff,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTimer } from '../contexts/TimerContext'
|
||||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import { taskStatusService } from '../services/taskStatusService'
|
||||
import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task'
|
||||
import { TaskKanbanView, COLUMNS } from '../components/tasks/TaskKanbanView'
|
||||
import { TaskKanbanView, DEFAULT_COLUMNS } from '../components/tasks/TaskKanbanView'
|
||||
import { TaskListView } from '../components/tasks/TaskListView'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
|
||||
|
|
@ -36,6 +38,19 @@ import {
|
|||
savePreferencesToBackend,
|
||||
} from '../lib/userPreferences'
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
'#71717a',
|
||||
'#0f766e',
|
||||
'#10b981',
|
||||
'#3b82f6',
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#f43f5e',
|
||||
'#f59e0b',
|
||||
'#64748b',
|
||||
]
|
||||
|
||||
export const TasksPage: React.FC = () => {
|
||||
const { activeOrg, user } = useAuth()
|
||||
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
||||
|
|
@ -47,18 +62,19 @@ export const TasksPage: React.FC = () => {
|
|||
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Kanban column visibility preferences
|
||||
const [hiddenColumns, setHiddenColumns] = useState<TaskStatus[]>([])
|
||||
const [hiddenColumns, setHiddenColumns] = useState<string[]>([])
|
||||
const [showAllColumns, setShowAllColumns] = useState(false)
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
|
||||
// Filters
|
||||
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')
|
||||
|
||||
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
|
||||
|
|
@ -86,13 +102,28 @@ export const TasksPage: React.FC = () => {
|
|||
|
||||
// Modal state
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED')
|
||||
const [modalInitialStatus, setModalInitialStatus] = useState<string>('A Fazer')
|
||||
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [isAiModalOpen, setIsAiModalOpen] = useState(false)
|
||||
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | 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
|
||||
useEffect(() => {
|
||||
if (!user?.email) return
|
||||
|
|
@ -112,7 +143,7 @@ export const TasksPage: React.FC = () => {
|
|||
|
||||
// Save hidden columns to backend
|
||||
const updateHiddenColumns = useCallback(
|
||||
(newHidden: TaskStatus[]) => {
|
||||
(newHidden: string[]) => {
|
||||
setHiddenColumns(newHidden)
|
||||
if (!user?.email) return
|
||||
const current = getLocalPreferences(user.email)
|
||||
|
|
@ -131,7 +162,7 @@ export const TasksPage: React.FC = () => {
|
|||
)
|
||||
|
||||
const handleHideColumn = useCallback(
|
||||
(status: TaskStatus) => {
|
||||
(status: string) => {
|
||||
if (!hiddenColumns.includes(status)) {
|
||||
updateHiddenColumns([...hiddenColumns, status])
|
||||
}
|
||||
|
|
@ -140,7 +171,7 @@ export const TasksPage: React.FC = () => {
|
|||
)
|
||||
|
||||
const handleUnhideColumn = useCallback(
|
||||
(status: TaskStatus) => {
|
||||
(status: string) => {
|
||||
updateHiddenColumns(hiddenColumns.filter((s) => s !== status))
|
||||
},
|
||||
[hiddenColumns, updateHiddenColumns]
|
||||
|
|
@ -152,18 +183,20 @@ export const TasksPage: React.FC = () => {
|
|||
|
||||
const activeOrgId = activeOrg?.id
|
||||
|
||||
// Load tasks & projects for current organization
|
||||
// Load tasks, projects & statuses for current organization
|
||||
const loadData = useCallback(async () => {
|
||||
if (!activeOrgId) return
|
||||
try {
|
||||
setRefreshing(true)
|
||||
const [tasksRes, projsRes] = await Promise.all([
|
||||
const [tasksRes, projsRes, statusesRes] = await Promise.all([
|
||||
taskService.listTasks({ organizationId: activeOrgId }),
|
||||
projectService.listProjects(activeOrgId).catch(() => []),
|
||||
taskStatusService.listStatuses(activeOrgId).catch(() => []),
|
||||
])
|
||||
|
||||
setTasks(tasksRes || [])
|
||||
setProjects(projsRes || [])
|
||||
setTaskStatuses(statusesRes || [])
|
||||
syncWithTasksRef.current(tasksRes || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks', err)
|
||||
|
|
@ -192,12 +225,38 @@ export const TasksPage: React.FC = () => {
|
|||
// Scope tasks based on viewScope
|
||||
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
|
||||
|
||||
// Effective columns for preferences & board
|
||||
const effectiveColumns = useMemo(() => {
|
||||
if (taskStatuses && taskStatuses.length > 0) {
|
||||
return [...taskStatuses]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.name,
|
||||
label: s.name,
|
||||
color: s.color || '#71717a',
|
||||
rawStatus: s,
|
||||
}))
|
||||
}
|
||||
return DEFAULT_COLUMNS
|
||||
}, [taskStatuses])
|
||||
|
||||
// Filter tasks client-side for ultra-responsive search
|
||||
const filteredTasks = useMemo(() => {
|
||||
return scopeTasks.filter((task) => {
|
||||
// Status filter
|
||||
if (selectedStatus !== 'ALL' && task.status !== selectedStatus) {
|
||||
return false
|
||||
if (selectedStatus !== 'ALL') {
|
||||
const tStatus = task.taskStatus?.name || task.status
|
||||
const matchesStatus =
|
||||
tStatus === selectedStatus ||
|
||||
task.status === selectedStatus ||
|
||||
(selectedStatus === 'A Fazer' && (task.status === 'NOT_STARTED' || task.taskStatus?.isDefault)) ||
|
||||
(selectedStatus === 'Em Andamento' && task.status === 'IN_PROGRESS') ||
|
||||
(selectedStatus === 'Concluído' && (task.status === 'COMPLETED' || task.taskStatus?.isCompleted)) ||
|
||||
(selectedStatus === 'Cancelado' && (task.status === 'CANCELED' || task.taskStatus?.isCanceled))
|
||||
if (!matchesStatus) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Project filter
|
||||
if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) {
|
||||
|
|
@ -241,14 +300,24 @@ export const TasksPage: React.FC = () => {
|
|||
}
|
||||
|
||||
// Status change handler
|
||||
const handleStatusChange = async (task: Task, newStatus: TaskStatus) => {
|
||||
const handleStatusChange = async (task: Task, newStatus: string, newStatusId?: number) => {
|
||||
setActionError(null)
|
||||
const matchedStatus = taskStatuses.find((s) => s.id === newStatusId || s.name === newStatus)
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, status: newStatus } : t))
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? {
|
||||
...t,
|
||||
status: newStatus,
|
||||
taskStatus: matchedStatus || t.taskStatus,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
const updated = await taskService.updateStatus(task.id, newStatus)
|
||||
const updated = await taskService.updateStatus(task.id, newStatus, newStatusId)
|
||||
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to update status', err)
|
||||
|
|
@ -261,7 +330,8 @@ export const TasksPage: React.FC = () => {
|
|||
const handleModalSubmit = async (taskData: {
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
status: string
|
||||
statusId?: number
|
||||
projectId?: number
|
||||
categoryId?: number
|
||||
assigneeUserId?: number
|
||||
|
|
@ -276,6 +346,7 @@ export const TasksPage: React.FC = () => {
|
|||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
statusId: taskData.statusId,
|
||||
projectId: taskData.projectId,
|
||||
categoryId: taskData.categoryId,
|
||||
assigneeUserId: taskData.assigneeUserId,
|
||||
|
|
@ -291,6 +362,7 @@ export const TasksPage: React.FC = () => {
|
|||
title: taskData.title,
|
||||
description: taskData.description,
|
||||
status: taskData.status,
|
||||
statusId: taskData.statusId,
|
||||
projectId: taskData.projectId,
|
||||
categoryId: taskData.categoryId,
|
||||
assigneeUserId: taskData.assigneeUserId,
|
||||
|
|
@ -301,7 +373,7 @@ export const TasksPage: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleOpenCreateModal = (initialStatus: TaskStatus = 'NOT_STARTED') => {
|
||||
const handleOpenCreateModal = (initialStatus: string = 'A Fazer') => {
|
||||
setEditingTask(null)
|
||||
setModalInitialStatus(initialStatus)
|
||||
setModalInitialTab('details')
|
||||
|
|
@ -343,11 +415,80 @@ export const TasksPage: React.FC = () => {
|
|||
[activeTask, stopTimer, editingTask]
|
||||
)
|
||||
|
||||
// Summary counts based on active scope
|
||||
const notStartedCount = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||
const inProgressCount = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||
const completedCount = scopeTasks.filter((t) => t.status === 'COMPLETED').length
|
||||
const canceledCount = scopeTasks.filter((t) => t.status === 'CANCELED').length
|
||||
// Status modal handlers
|
||||
const handleOpenStatusModal = (st: TaskStatus | null) => {
|
||||
if (st) {
|
||||
setEditingStatus(st)
|
||||
setStatusName(st.name)
|
||||
setStatusColor(st.color || COLOR_PRESETS[0])
|
||||
setStatusIsDefault(Boolean(st.isDefault))
|
||||
setStatusIsCompleted(Boolean(st.isCompleted))
|
||||
setStatusIsCanceled(Boolean(st.isCanceled))
|
||||
} else {
|
||||
setEditingStatus(null)
|
||||
setStatusName('')
|
||||
setStatusColor(COLOR_PRESETS[0])
|
||||
setStatusIsDefault(false)
|
||||
setStatusIsCompleted(false)
|
||||
setStatusIsCanceled(false)
|
||||
}
|
||||
setIsStatusModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveStatus = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!activeOrgId || !statusName.trim()) return
|
||||
|
||||
setStatusSubmitting(true)
|
||||
try {
|
||||
if (editingStatus) {
|
||||
await taskStatusService.updateStatus(activeOrgId, editingStatus.id, {
|
||||
name: statusName.trim(),
|
||||
color: statusColor,
|
||||
isDefault: statusIsDefault,
|
||||
isCompleted: statusIsCompleted,
|
||||
isCanceled: statusIsCanceled,
|
||||
})
|
||||
} else {
|
||||
await taskStatusService.createStatus(activeOrgId, {
|
||||
name: statusName.trim(),
|
||||
color: statusColor,
|
||||
isDefault: statusIsDefault,
|
||||
isCompleted: statusIsCompleted,
|
||||
isCanceled: statusIsCanceled,
|
||||
})
|
||||
}
|
||||
setIsStatusModalOpen(false)
|
||||
const updatedStatuses = await taskStatusService.listStatuses(activeOrgId)
|
||||
setTaskStatuses(updatedStatuses || [])
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
setActionError(err instanceof Error ? err.message : 'Falha ao salvar status.')
|
||||
} finally {
|
||||
setStatusSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDeleteStatusModal = (st: TaskStatus) => {
|
||||
const others = taskStatuses.filter((s) => s.id !== st.id)
|
||||
setMoveToStatusId(others.length > 0 ? others[0].id : undefined)
|
||||
setDeletingStatus(st)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteStatus = async () => {
|
||||
if (!activeOrgId || !deletingStatus) return
|
||||
setDeletingStatusLoading(true)
|
||||
try {
|
||||
await taskStatusService.deleteStatus(activeOrgId, deletingStatus.id, moveToStatusId)
|
||||
setDeletingStatus(null)
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
setActionError(err instanceof Error ? err.message : 'Falha ao excluir status.')
|
||||
} finally {
|
||||
setDeletingStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
Quadro de Tarefas
|
||||
</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="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
||||
<span className="text-[11px] font-bold px-2 py-0.5 rounded-full bg-brand/10 text-brand border border-brand/20">
|
||||
{activeOrg?.name || 'Workspace'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Planeje, execute e rastreie o tempo investido em cada entrega da equipe.
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
Monitore seu fluxo ágil, lance tempos de dedicação e entregue valor contínuo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View Switcher & New Task Button */}
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
{/* Refresh */}
|
||||
{/* View Mode & Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadData}
|
||||
disabled={refreshing}
|
||||
title="Recarregar tarefas"
|
||||
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"
|
||||
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"
|
||||
title="Atualizar tarefas"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Kanban / List Toggle */}
|
||||
<div className="flex items-center p-1 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel">
|
||||
{/* View toggle (Kanban vs List) */}
|
||||
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft">
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||
? 'bg-brand text-white shadow-xs'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
title="Visualização em Quadro (Kanban)"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<span>Kanban</span>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||
? 'bg-brand text-white shadow-xs'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
title="Visualização em Lista"
|
||||
>
|
||||
<ListIcon className="w-3.5 h-3.5" />
|
||||
<span>Lista</span>
|
||||
<ListIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kanban Columns Visibility Control */}
|
||||
{/* Kanban Columns Settings (Only in Kanban Mode) */}
|
||||
{viewMode === 'kanban' && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
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-medium transition-all shadow-panel ${
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-semibold transition-all ${
|
||||
hiddenColumns.length > 0
|
||||
? 'border-brand/40 bg-brand/10 text-brand'
|
||||
: 'border-border-soft bg-surface/90 text-muted hover:text-foreground hover:bg-surface-raised'
|
||||
? 'border-amber-500/50 bg-amber-500/10 text-amber-500 ring-1 ring-amber-500/30'
|
||||
: '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" />
|
||||
<span>Colunas</span>
|
||||
<span className="hidden sm:inline">Colunas</span>
|
||||
{hiddenColumns.length > 0 && (
|
||||
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-brand text-white font-bold">
|
||||
{hiddenColumns.length} oculta{hiddenColumns.length > 1 ? 's' : ''}
|
||||
<span className="w-4 h-4 rounded-full bg-amber-500 text-black text-[10px] font-bold flex items-center justify-center">
|
||||
{hiddenColumns.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Column Settings Dropdown */}
|
||||
{showColumnSettings && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
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="flex items-center justify-between pb-2 border-b border-border">
|
||||
<span className="text-xs font-bold text-foreground">Visibilidade de Colunas</span>
|
||||
{hiddenColumns.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnhideAllColumns}
|
||||
className="text-[11px] text-brand hover:underline font-medium"
|
||||
>
|
||||
Restaurar todas
|
||||
</button>
|
||||
)}
|
||||
<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 border-b border-border-soft pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-brand" />
|
||||
<h4 className="text-xs font-bold text-foreground">Colunas do Quadro</h4>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted font-medium">
|
||||
{effectiveColumns.length - hiddenColumns.length} de {effectiveColumns.length} ativas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toggle show all columns temporarily */}
|
||||
<div className="flex items-center justify-between p-2 rounded-xl bg-surface border border-border-soft">
|
||||
<div className="flex items-center gap-2">
|
||||
{showAllColumns ? (
|
||||
<Eye className="w-3.5 h-3.5 text-brand" />
|
||||
) : (
|
||||
<EyeOff className="w-3.5 h-3.5 text-muted" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-foreground">Mostrar ocultas</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAllColumns}
|
||||
onChange={(e) => setShowAllColumns(e.target.checked)}
|
||||
className="w-4 h-4 rounded text-brand border-border focus:ring-brand"
|
||||
/>
|
||||
{/* Master toggle */}
|
||||
<div className="flex items-center justify-between text-xs py-1">
|
||||
<span className="text-muted text-[11px]">Exibir todas as colunas:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showAllColumns) {
|
||||
setShowAllColumns(false)
|
||||
} else {
|
||||
handleUnhideAllColumns()
|
||||
setShowAllColumns(false)
|
||||
}
|
||||
}}
|
||||
className="text-xs font-semibold text-brand hover:underline"
|
||||
>
|
||||
{hiddenColumns.length === 0 ? 'Todas visíveis' : 'Reexibir todas'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Individual columns list */}
|
||||
<div className="space-y-1">
|
||||
{COLUMNS.map((col) => {
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{effectiveColumns.map((col) => {
|
||||
const isHidden = hiddenColumns.includes(col.status)
|
||||
return (
|
||||
<button
|
||||
key={col.status}
|
||||
key={col.id ? `col-${col.id}` : col.status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isHidden) {
|
||||
|
|
@ -490,22 +629,21 @@ export const TasksPage: React.FC = () => {
|
|||
: '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
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isHidden ? 'bg-zinc-500' : 'bg-brand'
|
||||
}`}
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: col.color || '#71717a' }}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="truncate">{col.label}</span>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<span>Oculta</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" />
|
||||
<span>Visível</span>
|
||||
</span>
|
||||
|
|
@ -514,6 +652,20 @@ export const TasksPage: React.FC = () => {
|
|||
)
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -536,7 +688,8 @@ export const TasksPage: React.FC = () => {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
setAiDraftTask(null)
|
||||
handleOpenCreateModal('NOT_STARTED')
|
||||
const defaultCol = taskStatuses.find((s) => s.isDefault)?.name || 'A Fazer'
|
||||
handleOpenCreateModal(defaultCol)
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
|
|
@ -579,8 +732,8 @@ export const TasksPage: React.FC = () => {
|
|||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>Individual (Minhas Tarefas)</span>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Minhas Tarefas</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
|
||||
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
|
||||
|
|
@ -589,6 +742,7 @@ export const TasksPage: React.FC = () => {
|
|||
{myTasks.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleScopeChange('team')}
|
||||
|
|
@ -646,7 +800,7 @@ export const TasksPage: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Statistics (Icon + Number) */}
|
||||
{/* Status Statistics (Dynamic Pills) */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap py-1">
|
||||
{/* Total / All */}
|
||||
<button
|
||||
|
|
@ -663,65 +817,51 @@ export const TasksPage: React.FC = () => {
|
|||
<span className="font-mono text-xs">{scopeTasks.length}</span>
|
||||
</button>
|
||||
|
||||
{/* A Fazer (NOT_STARTED) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStatus(selectedStatus === 'NOT_STARTED' ? 'ALL' : 'NOT_STARTED')}
|
||||
title="A Fazer"
|
||||
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'
|
||||
? 'bg-zinc-500/15 border-zinc-500/40 text-foreground ring-1 ring-zinc-500/30'
|
||||
: '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 className="font-mono text-xs text-foreground">{notStartedCount}</span>
|
||||
</button>
|
||||
{/* Dynamic Status Pills */}
|
||||
{effectiveColumns.map((col) => {
|
||||
const count = scopeTasks.filter((t) => {
|
||||
if (col.id && t.taskStatus?.id) return t.taskStatus.id === col.id
|
||||
const tStatus = t.status || ''
|
||||
if (tStatus === col.status || tStatus === col.label) return true
|
||||
if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) return true
|
||||
if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) return true
|
||||
if ((col.status === 'Concluído' || col.status === 'COMPLETED') && (tStatus === 'COMPLETED' || tStatus === 'Concluído' || t.taskStatus?.isCompleted)) return true
|
||||
if ((col.status === 'Cancelado' || col.status === 'CANCELED') && (tStatus === 'CANCELED' || tStatus === 'Cancelado' || t.taskStatus?.isCanceled)) return true
|
||||
return false
|
||||
}).length
|
||||
|
||||
{/* Em Andamento (IN_PROGRESS) */}
|
||||
<button
|
||||
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>
|
||||
const isSelected = selectedStatus === col.status
|
||||
|
||||
{/* 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>
|
||||
return (
|
||||
<button
|
||||
key={col.id ? `pill-${col.id}` : `pill-${col.status}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedStatus(isSelected ? 'ALL' : col.status)}
|
||||
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 ${
|
||||
isSelected
|
||||
? 'shadow-xs ring-1'
|
||||
: 'bg-surface-raised/60 border-border-soft text-muted hover:text-foreground hover:bg-surface-raised'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: col.color || '#71717a' }}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="font-mono text-xs">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -750,13 +890,14 @@ export const TasksPage: React.FC = () => {
|
|||
<div className="w-44">
|
||||
<CustomSelect
|
||||
value={selectedStatus}
|
||||
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
|
||||
onChange={(val) => setSelectedStatus(val as string | 'ALL')}
|
||||
options={[
|
||||
{ value: 'ALL', label: 'Todos os Status' },
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
...effectiveColumns.map((c) => ({
|
||||
value: c.status,
|
||||
label: c.label,
|
||||
color: c.color,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -792,7 +933,7 @@ export const TasksPage: React.FC = () => {
|
|||
</p>
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
|
|
@ -802,12 +943,16 @@ export const TasksPage: React.FC = () => {
|
|||
) : viewMode === 'kanban' ? (
|
||||
<TaskKanbanView
|
||||
tasks={filteredTasks}
|
||||
taskStatuses={taskStatuses}
|
||||
onToggleTimer={handleToggleTimer}
|
||||
onStatusChange={handleStatusChange}
|
||||
onEditTask={handleOpenEditModal}
|
||||
onOpenHistory={handleOpenHistoryModal}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onAddTaskToStatus={handleOpenCreateModal}
|
||||
onOpenCreateStatus={() => handleOpenStatusModal(null)}
|
||||
onEditStatus={(st) => handleOpenStatusModal(st)}
|
||||
onDeleteStatus={(st) => handleOpenDeleteStatusModal(st)}
|
||||
activeTaskId={activeTask?.id}
|
||||
activeTaskSeconds={activeSeconds}
|
||||
hiddenColumns={hiddenColumns}
|
||||
|
|
@ -818,6 +963,7 @@ export const TasksPage: React.FC = () => {
|
|||
) : (
|
||||
<TaskListView
|
||||
tasks={filteredTasks}
|
||||
taskStatuses={taskStatuses}
|
||||
onToggleTimer={handleToggleTimer}
|
||||
onStatusChange={handleStatusChange}
|
||||
onEditTask={handleOpenEditModal}
|
||||
|
|
@ -844,6 +990,7 @@ export const TasksPage: React.FC = () => {
|
|||
initialTask={editingTask}
|
||||
initialTab={modalInitialTab}
|
||||
initialDraft={aiDraftTask}
|
||||
taskStatuses={taskStatuses}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -856,11 +1003,186 @@ export const TasksPage: React.FC = () => {
|
|||
onSuccess={(parsed) => {
|
||||
setAiDraftTask(parsed)
|
||||
setEditingTask(null)
|
||||
setModalInitialStatus(parsed.status || 'NOT_STARTED')
|
||||
setModalInitialStatus(parsed.status || 'A Fazer')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task'
|
||||
import type { Task, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task'
|
||||
|
||||
function buildQueryParams(filter?: ListTasksFilter): string {
|
||||
if (!filter) return ''
|
||||
|
|
@ -57,9 +57,14 @@ export const taskService = {
|
|||
})
|
||||
},
|
||||
|
||||
async updateStatus(id: number, status: TaskStatus): Promise<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`, {
|
||||
method: 'PATCH',
|
||||
method: 'PUT',
|
||||
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 {
|
||||
id: number;
|
||||
|
|
@ -32,7 +44,8 @@ export interface Task {
|
|||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
status: string;
|
||||
taskStatus?: TaskStatus;
|
||||
project?: Project;
|
||||
category?: Category;
|
||||
user?: TaskUser;
|
||||
|
|
@ -51,7 +64,8 @@ export interface TaskHistoryItemInput {
|
|||
export interface CreateTaskPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
status?: string;
|
||||
statusId?: number;
|
||||
organizationId: number;
|
||||
projectId?: number;
|
||||
categoryId?: number;
|
||||
|
|
@ -67,13 +81,13 @@ export interface ParsedAiTask {
|
|||
categoryId?: number;
|
||||
assigneeUserId?: number;
|
||||
externalUrl?: string;
|
||||
status?: TaskStatus;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ListTasksFilter {
|
||||
organizationId?: number;
|
||||
text?: string;
|
||||
status?: TaskStatus[];
|
||||
status?: string[];
|
||||
projectId?: number[];
|
||||
categoryId?: number[];
|
||||
userId?: number[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue