feat(tasks): migrate task statuses to db table with org scope and dynamic board columns

This commit is contained in:
Gabriel Menezes 2026-11-09 11:43:37 -04:00
parent df368218fd
commit a865dea719
56 changed files with 2739 additions and 349 deletions

View file

@ -77,13 +77,13 @@ public class Task {
} }
public void setStatus(TaskStatus status) { public void setStatus(TaskStatus status) {
if (this.status == status) return; if (Objects.equals(this.status, status)) return;
if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) { if (status != null && (status.isCompleted() || status.isCanceled())) {
if (isRunning()) { if (isRunning()) {
this.stop(); this.stop();
} }
if (status == TaskStatus.COMPLETED) { if (status.isCompleted()) {
this.finishedAt = LocalDateTime.now(); this.finishedAt = LocalDateTime.now();
} else { } else {
this.finishedAt = null; this.finishedAt = null;
@ -157,7 +157,6 @@ public class Task {
throw new IllegalStateException("Tarefa já está em execução"); throw new IllegalStateException("Tarefa já está em execução");
} }
this.status = TaskStatus.IN_PROGRESS;
TaskHistoryItem item = new TaskHistoryItem(); TaskHistoryItem item = new TaskHistoryItem();
item.setStartAt(LocalDateTime.now()); item.setStartAt(LocalDateTime.now());
item.setUser(worker != null ? worker : this.user); item.setUser(worker != null ? worker : this.user);

View file

@ -1,18 +1,106 @@
package net.beetechgroup.beetask.entities.task; package net.beetechgroup.beetask.entities.task;
public enum TaskStatus { import java.util.Objects;
NOT_STARTED("NOT_STARTED"), import net.beetechgroup.beetask.entities.organization.Organization;
IN_PROGRESS("IN_PROGRESS"),
COMPLETED("COMPLETED"),
CANCELED("CANCELED");
private String status; public class TaskStatus {
private Long id;
private String name;
private String color;
private Integer position;
private boolean isDefault;
private boolean isCompleted;
private boolean isCanceled;
private Organization organization;
TaskStatus(String status) { public TaskStatus() {
this.status = status;
} }
public String getStatus() { public TaskStatus(Long id, String name, String color, Integer position, boolean isDefault, boolean isCompleted, boolean isCanceled, Organization organization) {
return status; this.id = id;
this.name = name;
this.color = color;
this.position = position;
this.isDefault = isDefault;
this.isCompleted = isCompleted;
this.isCanceled = isCanceled;
this.organization = organization;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Integer getPosition() {
return position != null ? position : 0;
}
public void setPosition(Integer position) {
this.position = position;
}
public boolean isDefault() {
return isDefault;
}
public void setDefault(boolean isDefault) {
this.isDefault = isDefault;
}
public boolean isCompleted() {
return isCompleted;
}
public void setCompleted(boolean isCompleted) {
this.isCompleted = isCompleted;
}
public boolean isCanceled() {
return isCanceled;
}
public void setCanceled(boolean isCanceled) {
this.isCanceled = isCanceled;
}
public Organization getOrganization() {
return organization;
}
public void setOrganization(Organization organization) {
this.organization = organization;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TaskStatus that = (TaskStatus) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
} }
} }

View file

@ -12,6 +12,7 @@ import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationA
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase; import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -33,8 +34,9 @@ public class OrganizationUseCaseConfig {
@Produces @Produces
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository, public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
UserRepository userRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository); TaskStatusRepository taskStatusRepository) {
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository, taskStatusRepository);
} }
@Produces @Produces

View file

@ -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);
}
}

View file

@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationM
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
@ -28,13 +29,15 @@ public class TaskUseCaseConfig {
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository, UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); TaskStatusRepository taskStatusRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
} }
@Produces @Produces
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository,
return new StartTaskUseCase(taskRepository, userRepository, authorizer); AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) {
return new StartTaskUseCase(taskRepository, userRepository, authorizer, taskStatusRepository);
} }
@Produces @Produces
@ -53,16 +56,19 @@ public class TaskUseCaseConfig {
} }
@Produces @Produces
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository,
return new UpdateTaskStatusUseCase(taskRepository, authorizer); TaskStatusRepository taskStatusRepository,
AuthorizeOrganizationMemberUseCase authorizer) {
return new UpdateTaskStatusUseCase(taskRepository, taskStatusRepository, authorizer);
} }
@Produces @Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository, UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); TaskStatusRepository taskStatusRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository);
} }
@Produces @Produces

View file

@ -16,7 +16,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.SQLRestriction;
import net.beetechgroup.beetask.entities.task.TaskStatus; import jakarta.persistence.FetchType;
@Entity @Entity
@Table(name = "tasks") @Table(name = "tasks")
@ -28,8 +28,11 @@ public class TaskEntity {
private Long id; private Long id;
private String title; private String title;
private String description; private String description;
@Enumerated(EnumType.STRING) @ManyToOne(fetch = FetchType.LAZY)
private TaskStatus status; @JoinColumn(name = "status_id")
private TaskStatusEntity taskStatus;
@Column(name = "status", length = 120)
private String status;
@Column(name = "finished_at") @Column(name = "finished_at")
private java.time.LocalDateTime finishedAt; private java.time.LocalDateTime finishedAt;
@Column(name = "external_url", length = 1000) @Column(name = "external_url", length = 1000)
@ -103,11 +106,19 @@ public class TaskEntity {
this.description = description; this.description = description;
} }
public TaskStatus getStatus() { public TaskStatusEntity getTaskStatus() {
return taskStatus;
}
public void setTaskStatus(TaskStatusEntity taskStatus) {
this.taskStatus = taskStatus;
}
public String getStatus() {
return status; return status;
} }
public void setStatus(TaskStatus status) { public void setStatus(String status) {
this.status = status; this.status = status;
} }

View file

@ -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;
}
}

View file

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity; import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity;
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity; import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity;
@ -14,7 +15,8 @@ public class TaskEntityMapper {
entity.setId(task.getId()); entity.setId(task.getId());
entity.setTitle(task.getTitle()); entity.setTitle(task.getTitle());
entity.setDescription(task.getDescription()); entity.setDescription(task.getDescription());
entity.setStatus(task.getStatus()); entity.setTaskStatus(TaskStatusEntityMapper.toEntity(task.getStatus()));
entity.setStatus(task.getStatus() != null ? task.getStatus().getName() : null);
entity.setFinishedAt(task.getFinishedAt()); entity.setFinishedAt(task.getFinishedAt());
entity.setExternalUrl(task.getExternalUrl()); entity.setExternalUrl(task.getExternalUrl());
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization())); entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
@ -37,7 +39,13 @@ public class TaskEntityMapper {
task.setId(entity.getId()); task.setId(entity.getId());
task.setTitle(entity.getTitle()); task.setTitle(entity.getTitle());
task.setDescription(entity.getDescription()); task.setDescription(entity.getDescription());
task.setStatus(entity.getStatus()); if (entity.getTaskStatus() != null) {
task.setStatus(TaskStatusEntityMapper.toDomain(entity.getTaskStatus()));
} else if (entity.getStatus() != null) {
TaskStatus fallback = new TaskStatus();
fallback.setName(entity.getStatus());
task.setStatus(fallback);
}
task.setFinishedAt(entity.getFinishedAt()); task.setFinishedAt(entity.getFinishedAt());
task.setExternalUrl(entity.getExternalUrl()); task.setExternalUrl(entity.getExternalUrl());
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization())); task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));

View file

@ -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;
}
}

View file

@ -55,7 +55,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds, public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) { List<String> statuses, List<Long> categoryIds, List<Long> userIds) {
StringBuilder query = new StringBuilder( StringBuilder query = new StringBuilder(
"select distinct t from TaskEntity t left join t.project p left join t.category c " + "select distinct t from TaskEntity t left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)" "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)"
@ -77,7 +77,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
idx++; idx++;
} }
if (Objects.nonNull(statuses) && !statuses.isEmpty()) { if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
query.append(" and t.status in ?").append(idx); query.append(" and (t.status in ?").append(idx).append(" or lower(t.taskStatus.name) in ?").append(idx).append(")");
params.add(statuses); params.add(statuses);
idx++; idx++;
} }
@ -99,7 +99,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds, public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) { List<String> statuses, List<Long> categoryIds) {
StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))"); StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
params.add(email); params.add(email);
@ -118,7 +118,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
idx++; idx++;
} }
if (Objects.nonNull(statuses) && !statuses.isEmpty()) { if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
query.append(" and status in ?").append(idx); query.append(" and (status in ?").append(idx).append(" or lower(taskStatus.name) in ?").append(idx).append(")");
params.add(statuses); params.add(statuses);
idx++; idx++;
} }
@ -167,8 +167,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
return find("user.email = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", return find("user.email = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
email, TaskStatus.COMPLETED, start, end) email, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@ -193,8 +193,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return find("select distinct t from TaskEntity t " + return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " + "left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " + "and t.finishedAt is not null and t.finishedAt >= ?2 and t.finishedAt <= ?3",
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@ -225,8 +224,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", return find("user.id = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
userId, TaskStatus.COMPLETED, start, end) userId, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@ -235,8 +234,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return find("select distinct t from TaskEntity t " + return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " + "left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5", "and t.user.id = ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
orgId, userId, TaskStatus.COMPLETED, start, end) orgId, userId, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@ -279,8 +278,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
LOGGER.warn("Finished task search by user emails skipped because the email list is empty"); LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
return List.of(); return List.of();
} }
return find("user.email in ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", return find("user.email in ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3",
emails, TaskStatus.COMPLETED, start, end) emails, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@ -293,14 +292,14 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return find("select distinct t from TaskEntity t " + return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " + "left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5", "and t.user.email in ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4",
orgId, emails, TaskStatus.COMPLETED, start, end) orgId, emails, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@Override @Override
public long countByStatus(TaskStatus status) { public long countCompletedTasks() {
return count("status", status); return count("finishedAt is not null or (taskStatus is not null and taskStatus.isCompleted = true)");
} }
@Override @Override

View file

@ -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());
}
}
}

View file

@ -2,12 +2,11 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record CreateTaskRequest( public record CreateTaskRequest(
String title, String title,
String description, String description,
TaskStatus status, String status,
Long statusId,
Long organizationId, Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,

View file

@ -3,13 +3,12 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record CreateTaskResponse( public record CreateTaskResponse(
Long id, Long id,
String title, String title,
String description, String description,
TaskStatus status, String status,
TaskStatusResponse taskStatus,
ProjectResponse project, ProjectResponse project,
CategoryResponse category, CategoryResponse category,
UserResponse user, UserResponse user,
@ -18,8 +17,16 @@ public record CreateTaskResponse(
List<TaskHistoryItemResponse> history, List<TaskHistoryItemResponse> history,
String externalUrl) { String externalUrl) {
public record TaskStatusResponse(
Long id,
String name,
String color,
Integer position,
boolean isDefault,
boolean isCompleted,
boolean isCanceled) {}
public record ProjectResponse(Long id, String name) {} public record ProjectResponse(Long id, String name) {}
public record CategoryResponse(Long id, String name, String color, String icon) {} public record CategoryResponse(Long id, String name, String color, String icon) {}
public record UserResponse(Long id, String name, String email, String photo) {} public record UserResponse(Long id, String name, String email, String photo) {}
} }

View file

@ -1,9 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task; package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.List; import java.util.List;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public class ListMyTasksRequest { public class ListMyTasksRequest {
@ -14,7 +12,7 @@ public class ListMyTasksRequest {
public List<Long> projectIds; public List<Long> projectIds;
@QueryParam("status") @QueryParam("status")
public List<TaskStatus> statuses; public List<String> statuses;
@QueryParam("categoryId") @QueryParam("categoryId")
public List<Long> categoryIds; public List<Long> categoryIds;

View file

@ -1,9 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task; package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.List; import java.util.List;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public class ListTasksRequest { public class ListTasksRequest {
@ -17,7 +15,7 @@ public class ListTasksRequest {
public List<Long> projectIds; public List<Long> projectIds;
@QueryParam("status") @QueryParam("status")
public List<TaskStatus> statuses; public List<String> statuses;
@QueryParam("categoryId") @QueryParam("categoryId")
public List<Long> categoryIds; public List<Long> categoryIds;

View file

@ -24,7 +24,6 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
@ -145,15 +144,41 @@ public class TaskController {
return TaskControllerMapper.toCreateTaskResponse(output); return TaskControllerMapper.toCreateTaskResponse(output);
} }
@PUT
@Path("/{id}/status")
@Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
@Operation(summary = "Update task status (PUT)", description = "Updates the status of a task")
@APIResponse(responseCode = "200", description = "Task status updated successfully")
public CreateTaskResponse updateTaskStatusPut(@PathParam("id") Long id, String rawStatus) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Status update (PUT) requested for task %d to %s by %s", id, rawStatus, email);
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email);
LOGGER.infof("Task %d status updated successfully", id);
return TaskControllerMapper.toCreateTaskResponse(output);
}
@PATCH @PATCH
@Path("/{id}/status") @Path("/{id}/status")
@Operation(summary = "Update task status", description = "Updates the status of a task") @Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
@Operation(summary = "Update task status (PATCH)", description = "Updates the status of a task")
@APIResponse(responseCode = "200", description = "Task status updated successfully") @APIResponse(responseCode = "200", description = "Task status updated successfully")
public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) { public CreateTaskResponse updateTaskStatusPatch(@PathParam("id") Long id, String rawStatus) {
String email = securityIdentity.getPrincipal().getName(); String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email); LOGGER.infof("Status update (PATCH) requested for task %d to %s by %s", id, rawStatus, email);
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email); CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email);
LOGGER.infof("Task %d status updated successfully to %s", id, status); LOGGER.infof("Task %d status updated successfully", id);
return TaskControllerMapper.toCreateTaskResponse(output);
}
@PUT
@Path("/{id}/status/{statusId}")
@Operation(summary = "Update task status by ID", description = "Updates the status of a task by status ID")
@APIResponse(responseCode = "200", description = "Task status updated successfully")
public CreateTaskResponse updateTaskStatusById(@PathParam("id") Long id, @PathParam("statusId") Long statusId) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Status update by ID requested for task %d to status %d by %s", id, statusId, email);
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, statusId, null, email);
LOGGER.infof("Task %d status updated successfully to status %d", id, statusId);
return TaskControllerMapper.toCreateTaskResponse(output); return TaskControllerMapper.toCreateTaskResponse(output);
} }

View file

@ -1,5 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task; package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -12,20 +14,42 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper { public class TaskControllerMapper {
private static List<String> normalizeStatuses(List<String> rawStatuses) {
if (rawStatuses == null || rawStatuses.isEmpty()) {
return rawStatuses;
}
List<String> normalized = new ArrayList<>();
for (String s : rawStatuses) {
if (s == null) continue;
normalized.add(s);
String trimmed = s.trim();
if ("NOT_STARTED".equalsIgnoreCase(trimmed) && !normalized.contains("A Fazer")) {
normalized.add("A Fazer");
} else if ("IN_PROGRESS".equalsIgnoreCase(trimmed) && !normalized.contains("Em Andamento")) {
normalized.add("Em Andamento");
} else if ("COMPLETED".equalsIgnoreCase(trimmed) && !normalized.contains("Concluído")) {
normalized.add("Concluído");
} else if ("CANCELED".equalsIgnoreCase(trimmed) && !normalized.contains("Cancelado")) {
normalized.add("Cancelado");
}
}
return normalized;
}
public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) { public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) {
return new ListTasksInput( return new ListTasksInput(
email, email,
request.organizationId, request.organizationId,
request.text, request.text,
request.projectIds, request.projectIds,
request.statuses, normalizeStatuses(request.statuses),
request.categoryIds, request.categoryIds,
request.userIds request.userIds
); );
} }
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) { public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds); return new ListMyTasksInput(email, request.text, request.projectIds, normalizeStatuses(request.statuses), request.categoryIds);
} }
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) { public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
@ -33,6 +57,7 @@ public class TaskControllerMapper {
request.title(), request.title(),
request.description(), request.description(),
request.status(), request.status(),
request.statusId(),
request.organizationId(), request.organizationId(),
request.projectId(), request.projectId(),
request.categoryId(), request.categoryId(),
@ -50,7 +75,15 @@ public class TaskControllerMapper {
output.id(), output.id(),
output.title(), output.title(),
output.description(), output.description(),
output.status(), output.status() != null ? output.status().getName() : null,
output.status() != null ? new CreateTaskResponse.TaskStatusResponse(
output.status().getId(),
output.status().getName(),
output.status().getColor(),
output.status().getPosition(),
output.status().isDefault(),
output.status().isCompleted(),
output.status().isCanceled()) : null,
Objects.nonNull(output.project()) Objects.nonNull(output.project())
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name()) ? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
: null, : null,
@ -96,6 +129,7 @@ public class TaskControllerMapper {
request.title(), request.title(),
request.description(), request.description(),
request.status(), request.status(),
request.statusId(),
request.organizationId(), request.organizationId(),
request.projectId(), request.projectId(),
request.categoryId(), request.categoryId(),

View file

@ -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();
}
}

View file

@ -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()
);
}
}

View file

@ -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) {
}

View file

@ -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) {
}

View file

@ -5,7 +5,9 @@ import net.beetechgroup.beetask.entities.organization.Organization;
import net.beetechgroup.beetask.entities.organization.UserOrganization; import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -17,13 +19,16 @@ public class CreateOrganizationUseCase {
private final OrganizationRepository organizationRepository; private final OrganizationRepository organizationRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository; private final UserOrganizationRepository userOrganizationRepository;
private final TaskStatusRepository taskStatusRepository;
public CreateOrganizationUseCase(OrganizationRepository organizationRepository, public CreateOrganizationUseCase(OrganizationRepository organizationRepository,
UserRepository userRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
TaskStatusRepository taskStatusRepository) {
this.organizationRepository = organizationRepository; this.organizationRepository = organizationRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository; this.userOrganizationRepository = userOrganizationRepository;
this.taskStatusRepository = taskStatusRepository;
} }
public CreateOrganizationOutput execute(CreateOrganizationInput input) { public CreateOrganizationOutput execute(CreateOrganizationInput input) {
@ -59,6 +64,17 @@ public class CreateOrganizationUseCase {
userOrganizationRepository.save(userOrganization); userOrganizationRepository.save(userOrganization);
LOGGER.infof("Organization %d created successfully with owner user %d", organization.getId(), user.getId()); LOGGER.infof("Organization %d created successfully with owner user %d", organization.getId(), user.getId());
// Create 4 default task statuses
createDefaultTaskStatuses(organization);
return CreateOrganizationMapper.toCreateOrganizationOutput(organization); return CreateOrganizationMapper.toCreateOrganizationOutput(organization);
} }
private void createDefaultTaskStatuses(Organization org) {
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "A Fazer", "#71717a", 0, true, false, false, org));
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Em Andamento", "#14b8a6", 1, false, false, false, org));
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Concluído", "#10b981", 2, false, true, false, org));
taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Cancelado", "#f43f5e", 3, false, false, true, org));
LOGGER.infof("Created 4 default task statuses for organization %d", org.getId());
}
} }

View file

@ -1,6 +1,5 @@
package net.beetechgroup.beetask.usecase.publicstats; package net.beetechgroup.beetask.usecase.publicstats;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -19,7 +18,7 @@ public class PublicStatsUseCase {
public PublicStatsOutput execute() { public PublicStatsOutput execute() {
long totalUsers = userRepository.countAll(); long totalUsers = userRepository.countAll();
long totalOrganizations = organizationRepository.countAll(); long totalOrganizations = organizationRepository.countAll();
long totalCompletedTasks = taskRepository.countByStatus(TaskStatus.COMPLETED); long totalCompletedTasks = taskRepository.countCompletedTasks();
double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0; double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0;
return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours); return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours);

View file

@ -423,7 +423,14 @@ public class GenerateReportUseCase {
} }
} }
if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) { if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) {
if (t.getStatus() == null || !t.getStatus().name().equalsIgnoreCase(section.getFilterStatus())) { String statusName = t.getStatus() != null ? t.getStatus().getName() : "";
String filter = section.getFilterStatus();
boolean match = statusName.equalsIgnoreCase(filter)
|| ("NOT_STARTED".equalsIgnoreCase(filter) && "A Fazer".equalsIgnoreCase(statusName))
|| ("IN_PROGRESS".equalsIgnoreCase(filter) && "Em Andamento".equalsIgnoreCase(statusName))
|| ("COMPLETED".equalsIgnoreCase(filter) && "Concluído".equalsIgnoreCase(statusName))
|| ("CANCELED".equalsIgnoreCase(filter) && "Cancelado".equalsIgnoreCase(statusName));
if (!match) {
return false; return false;
} }
} }
@ -457,13 +464,8 @@ public class GenerateReportUseCase {
} }
private String formatStatus(TaskStatus status) { private String formatStatus(TaskStatus status) {
if (status == null) return ""; if (status == null || status.getName() == null) return "";
return switch (status) { return status.getName();
case COMPLETED -> "Concluído";
case IN_PROGRESS -> "Em Andamento";
case NOT_STARTED -> "A Fazer";
case CANCELED -> "Cancelado";
};
} }
private String capitalize(String text) { private String capitalize(String text) {

View file

@ -11,14 +11,14 @@ public interface TaskRepository {
Task findTaskById(Long id); Task findTaskById(Long id);
List<Task> findAllTasks(); List<Task> findAllTasks();
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds, List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds); List<String> statuses, List<Long> categoryIds, List<Long> userIds);
List<Task> findRunningTasksByUserEmail(String email); List<Task> findRunningTasksByUserEmail(String email);
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId); List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email); List<Task> findTasksByUser(String email);
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds, List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds); List<String> statuses, List<Long> categoryIds);
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
@ -29,7 +29,7 @@ public interface TaskRepository {
List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
long countByStatus(TaskStatus status); long countCompletedTasks();
long sumAllCompletedTrackedMinutes(); long sumAllCompletedTrackedMinutes();
void deleteTask(Long id); void deleteTask(Long id);
} }

View file

@ -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);
}

View file

@ -1,13 +1,13 @@
package net.beetechgroup.beetask.usecase.task.create; package net.beetechgroup.beetask.usecase.task.create;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput; import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
public record CreateTaskInput( public record CreateTaskInput(
String title, String title,
String description, String description,
TaskStatus status, String status,
Long statusId,
Long organizationId, Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,

View file

@ -8,11 +8,13 @@ import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@ -24,17 +26,20 @@ public class CreateTaskUseCase {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository; private final UserOrganizationRepository userOrganizationRepository;
private final OrganizationRepository organizationRepository; private final OrganizationRepository organizationRepository;
private final TaskStatusRepository taskStatusRepository;
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository, UserOrganizationRepository userOrganizationRepository,
OrganizationRepository organizationRepository) { OrganizationRepository organizationRepository,
TaskStatusRepository taskStatusRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository; this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository; this.organizationRepository = organizationRepository;
this.taskStatusRepository = taskStatusRepository;
} }
public CreateTaskOutput execute(CreateTaskInput input) { public CreateTaskOutput execute(CreateTaskInput input) {
@ -42,7 +47,6 @@ public class CreateTaskUseCase {
Task task = new Task(); Task task = new Task();
task.setTitle(input.title()); task.setTitle(input.title());
task.setDescription(input.description()); task.setDescription(input.description());
task.setStatus(input.status());
task.setExternalUrl(input.externalUrl()); task.setExternalUrl(input.externalUrl());
if (Objects.nonNull(input.projectId())) { if (Objects.nonNull(input.projectId())) {
@ -72,6 +76,28 @@ public class CreateTaskUseCase {
task.setOrganization(task.getCategory().getOrganization()); task.setOrganization(task.getCategory().getOrganization());
} }
// Resolve task status
TaskStatus taskStatus = null;
if (input.statusId() != null) {
taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null);
}
if (taskStatus == null && input.status() != null && !input.status().isBlank() && task.getOrganization() != null) {
String name = input.status().trim();
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, task.getOrganization().getId()).orElse(null);
}
if (taskStatus == null && task.getOrganization() != null) {
taskStatus = taskStatusRepository.findDefaultByOrganizationId(task.getOrganization().getId())
.orElseGet(() -> {
List<TaskStatus> all = taskStatusRepository.findByOrganizationId(task.getOrganization().getId());
return all.isEmpty() ? null : all.get(0);
});
}
task.setStatus(taskStatus);
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
User creator = userRepository.findByEmail(input.userEmail()).orElse(null); User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
task.setCreatedBy(creator); task.setCreatedBy(creator);
@ -87,7 +113,8 @@ public class CreateTaskUseCase {
} }
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
LOGGER.infof("Task %d created for user %s", output.id(), input.userEmail()); LOGGER.infof("Task %d created for user %s with status %s", output.id(), input.userEmail(),
task.getStatus() != null ? task.getStatus().getName() : "none");
return output; return output;
} }

View file

@ -2,7 +2,5 @@ package net.beetechgroup.beetask.usecase.task.listall;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record ListMyTasksInput(String email, String text, List<Long> projectIds, public record ListMyTasksInput(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) {} List<String> statuses, List<Long> categoryIds) {}

View file

@ -2,13 +2,11 @@ package net.beetechgroup.beetask.usecase.task.listall;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record ListTasksInput( public record ListTasksInput(
String userEmail, String userEmail,
Long organizationId, Long organizationId,
String text, String text,
List<Long> projectIds, List<Long> projectIds,
List<TaskStatus> statuses, List<String> statuses,
List<Long> categoryIds, List<Long> categoryIds,
List<Long> userIds) {} List<Long> userIds) {}

View file

@ -2,8 +2,10 @@ package net.beetechgroup.beetask.usecase.task.start;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -15,11 +17,14 @@ public class StartTaskUseCase {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuthorizeOrganizationMemberUseCase authorizer; private final AuthorizeOrganizationMemberUseCase authorizer;
private final TaskStatusRepository taskStatusRepository;
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository,
AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.authorizer = authorizer; this.authorizer = authorizer;
this.taskStatusRepository = taskStatusRepository;
} }
public CreateTaskOutput execute(StartTaskInput input) { public CreateTaskOutput execute(StartTaskInput input) {
@ -57,10 +62,15 @@ public class StartTaskUseCase {
} }
} }
// If task is not started or in default status, move to "Em Andamento"
if (orgId != null && (task.getStatus() == null || task.getStatus().isDefault() || "A Fazer".equalsIgnoreCase(task.getStatus().getName()))) {
taskStatusRepository.findByNameAndOrganizationId("Em Andamento", orgId)
.ifPresent(task::setStatus);
}
task.start(currentUser); task.start(currentUser);
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
LOGGER.infof("Task %d started successfully", input.id()); LOGGER.infof("Task %d started successfully", input.id());
return output; return output;
} }
} }

View file

@ -1,13 +1,13 @@
package net.beetechgroup.beetask.usecase.task.update; package net.beetechgroup.beetask.usecase.task.update;
import java.util.List; import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record UpdateTaskInput( public record UpdateTaskInput(
Long id, Long id,
String title, String title,
String description, String description,
TaskStatus status, String status,
Long statusId,
Long organizationId, Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,

View file

@ -4,6 +4,7 @@ import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@ -12,19 +13,25 @@ public class UpdateTaskStatusUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class); private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final TaskStatusRepository taskStatusRepository;
private final AuthorizeOrganizationMemberUseCase authorizer; private final AuthorizeOrganizationMemberUseCase authorizer;
public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { public UpdateTaskStatusUseCase(TaskRepository taskRepository,
TaskStatusRepository taskStatusRepository,
AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.taskStatusRepository = taskStatusRepository;
this.authorizer = authorizer; this.authorizer = authorizer;
} }
public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) { public CreateTaskOutput execute(Long id, Long newStatusId, String newStatusName, String userEmail) {
LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail); LOGGER.infof("Updating status for task %d (statusId=%s, statusName=%s) by user %s", id, newStatusId, newStatusName, userEmail);
Task task = taskRepository.findTaskById(id); Task task = taskRepository.findTaskById(id);
Long orgId = null; Long orgId = null;
if (task.getProject() != null && task.getProject().getOrganization() != null) { if (task.getOrganization() != null) {
orgId = task.getOrganization().getId();
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
orgId = task.getProject().getOrganization().getId(); orgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
orgId = task.getCategory().getOrganization().getId(); orgId = task.getCategory().getOrganization().getId();
@ -37,11 +44,45 @@ public class UpdateTaskStatusUseCase {
throw new SecurityException("Você não tem permissão para atualizar esta tarefa."); throw new SecurityException("Você não tem permissão para atualizar esta tarefa.");
} }
task.setStatus(newStatus); TaskStatus resolved = null;
if (newStatusId != null) {
resolved = taskStatusRepository.findTaskStatusById(newStatusId).orElse(null);
}
if (resolved == null && newStatusName != null && !newStatusName.isBlank() && orgId != null) {
String name = newStatusName.trim();
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
resolved = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null);
}
if (resolved == null) {
throw new IllegalArgumentException("Status não encontrado para a tarefa.");
}
task.setStatus(resolved);
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
LOGGER.infof("Status updated for task %d to %s", id, newStatus); LOGGER.infof("Status updated for task %d to %s", id, resolved.getName());
return output; return output;
} }
}
public CreateTaskOutput execute(Long id, String rawStatus, String userEmail) {
if (rawStatus == null || rawStatus.isBlank()) {
throw new IllegalArgumentException("Status é obrigatório.");
}
String clean = rawStatus.trim();
if (clean.startsWith("\"") && clean.endsWith("\"") && clean.length() >= 2) {
clean = clean.substring(1, clean.length() - 1).trim();
}
Long statusId = null;
try {
statusId = Long.parseLong(clean);
} catch (NumberFormatException ignored) {
}
return execute(id, statusId, clean, userEmail);
}
}

View file

@ -8,9 +8,11 @@ import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
@ -25,17 +27,20 @@ public class UpdateTaskUseCase {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository; private final UserOrganizationRepository userOrganizationRepository;
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository; private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
private final TaskStatusRepository taskStatusRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository, UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository,
TaskStatusRepository taskStatusRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository; this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository; this.organizationRepository = organizationRepository;
this.taskStatusRepository = taskStatusRepository;
} }
public CreateTaskOutput execute(UpdateTaskInput input) { public CreateTaskOutput execute(UpdateTaskInput input) {
@ -58,9 +63,28 @@ public class UpdateTaskUseCase {
task.setTitle(input.title()); task.setTitle(input.title());
task.setDescription(input.description()); task.setDescription(input.description());
task.setStatus(input.status());
task.setExternalUrl(input.externalUrl()); task.setExternalUrl(input.externalUrl());
if (input.statusId() != null || (input.status() != null && !input.status().isBlank())) {
TaskStatus taskStatus = null;
if (input.statusId() != null) {
taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null);
}
Long orgId = input.organizationId() != null ? input.organizationId() :
(task.getOrganization() != null ? task.getOrganization().getId() : null);
if (taskStatus == null && input.status() != null && !input.status().isBlank() && orgId != null) {
String name = input.status().trim();
if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer";
else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento";
else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído";
else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado";
taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null);
}
if (taskStatus != null) {
task.setStatus(taskStatus);
}
}
if (Objects.nonNull(input.organizationId())) { if (Objects.nonNull(input.organizationId())) {
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization); organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
} }

View file

@ -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) {
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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
);
}
}

View file

@ -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) {
}

View file

@ -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);
}
}

View file

@ -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) {
}

View file

@ -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;

View file

@ -12,6 +12,7 @@ import { OrganizationsPage } from './pages/OrganizationsPage'
import { TasksPage } from './pages/TasksPage' import { TasksPage } from './pages/TasksPage'
import { ProjectsPage } from './pages/ProjectsPage' import { ProjectsPage } from './pages/ProjectsPage'
import { CategoriesPage } from './pages/CategoriesPage' import { CategoriesPage } from './pages/CategoriesPage'
import { StatusesPage } from './pages/StatusesPage'
import { MembersPage } from './pages/MembersPage' import { MembersPage } from './pages/MembersPage'
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage' import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
import { AnalyticsPage } from './pages/AnalyticsPage' import { AnalyticsPage } from './pages/AnalyticsPage'
@ -45,6 +46,7 @@ export function App() {
<Route path="/kanban" element={<TasksPage />} /> <Route path="/kanban" element={<TasksPage />} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/statuses" element={<StatusesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/members" element={<MembersPage />} /> <Route path="/members" element={<MembersPage />} />
<Route path="/organization/settings" element={<OrganizationSettingsPage />} /> <Route path="/organization/settings" element={<OrganizationSettingsPage />} />

View file

@ -21,6 +21,7 @@ import {
LogOut, LogOut,
FileText, FileText,
Lock, Lock,
CircleDot,
} from 'lucide-react' } from 'lucide-react'
import { Chip, ChipLabel } from '@heroui/react' import { Chip, ChipLabel } from '@heroui/react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
@ -95,6 +96,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
links: [ links: [
{ to: '/projects', label: 'Projetos', icon: <FolderKanban className="w-4 h-4" /> }, { to: '/projects', label: 'Projetos', icon: <FolderKanban className="w-4 h-4" /> },
{ to: '/categories', label: 'Categorias', icon: <Tags className="w-4 h-4" /> }, { to: '/categories', label: 'Categorias', icon: <Tags className="w-4 h-4" /> },
{ to: '/statuses', label: 'Status', icon: <CircleDot className="w-4 h-4" /> },
], ],
}, },
{ {

View file

@ -23,6 +23,7 @@ import { projectService } from '../../services/projectService'
import { categoryService } from '../../services/categoryService' import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService' import { organizationService } from '../../services/organizationService'
import { taskService } from '../../services/taskService' import { taskService } from '../../services/taskService'
import { taskStatusService } from '../../services/taskStatusService'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions' import { canDeleteTask } from '../../lib/taskPermissions'
@ -42,7 +43,8 @@ interface CreateTaskModalProps {
onSubmit: (taskData: { onSubmit: (taskData: {
title: string title: string
description: string description: string
status: TaskStatus status: string
statusId?: number
projectId?: number projectId?: number
categoryId?: number categoryId?: number
assigneeUserId?: number assigneeUserId?: number
@ -51,10 +53,11 @@ interface CreateTaskModalProps {
}) => Promise<void> }) => Promise<void>
onDelete?: (task: Task) => void onDelete?: (task: Task) => void
organizationId: number organizationId: number
initialStatus?: TaskStatus initialStatus?: string
initialTask?: Task | null initialTask?: Task | null
initialTab?: 'details' | 'history' initialTab?: 'details' | 'history'
initialDraft?: Partial<ParsedAiTask> | null initialDraft?: Partial<ParsedAiTask> | null
taskStatuses?: TaskStatus[]
} }
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
@ -63,10 +66,11 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
onSubmit, onSubmit,
onDelete, onDelete,
organizationId, organizationId,
initialStatus = 'NOT_STARTED', initialStatus = 'A Fazer',
initialTask = null, initialTask = null,
initialTab = 'details', initialTab = 'details',
initialDraft = null, initialDraft = null,
taskStatuses: propTaskStatuses,
}) => { }) => {
const { user, activeOrg } = useAuth() const { user, activeOrg } = useAuth()
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details') const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
@ -74,7 +78,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
// Task details // Task details
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [status, setStatus] = useState<TaskStatus>(initialStatus) const [status, setStatus] = useState<string>(initialStatus)
const [projectId, setProjectId] = useState<number | undefined>(undefined) const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined) const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined) const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined)
@ -95,6 +99,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>(propTaskStatuses || [])
const [members, setMembers] = useState<OrganizationMember[]>([]) const [members, setMembers] = useState<OrganizationMember[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -214,14 +219,18 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
const loadMetadata = async () => { const loadMetadata = async () => {
setLoading(true) setLoading(true)
try { try {
const [projRes, catRes, memRes] = await Promise.allSettled([ const [projRes, catRes, memRes, statRes] = await Promise.allSettled([
projectService.listProjects(organizationId), projectService.listProjects(organizationId),
categoryService.listCategories(organizationId), categoryService.listCategories(organizationId),
organizationService.listMembers(organizationId), organizationService.listMembers(organizationId),
propTaskStatuses && propTaskStatuses.length > 0
? Promise.resolve(propTaskStatuses)
: taskStatusService.listStatuses(organizationId),
]) ])
if (projRes.status === 'fulfilled') setProjects(projRes.value || []) if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
if (catRes.status === 'fulfilled') setCategories(catRes.value || []) if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
if (statRes.status === 'fulfilled') setTaskStatuses(statRes.value || [])
if (memRes.status === 'fulfilled') { if (memRes.status === 'fulfilled') {
const memberList = memRes.value || [] const memberList = memRes.value || []
setMembers(memberList) setMembers(memberList)
@ -427,10 +436,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
effectiveAssigneeUserId = undefined effectiveAssigneeUserId = undefined
} }
const matchedStatus = taskStatuses.find((s) => s.name === status)
await onSubmit({ await onSubmit({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
status, status,
statusId: matchedStatus?.id,
projectId, projectId,
categoryId, categoryId,
assigneeUserId: effectiveAssigneeUserId, assigneeUserId: effectiveAssigneeUserId,
@ -769,13 +781,23 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
</label> </label>
<CustomSelect <CustomSelect
value={status} value={status}
onChange={(val) => setStatus(val as TaskStatus)} onChange={(val) => setStatus(val)}
options={[ options={
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, taskStatuses.length > 0
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, ? [...taskStatuses]
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, .sort((a, b) => a.position - b.position)
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' }, .map((s) => ({
]} value: s.name,
label: s.name,
color: s.color || '#71717a',
}))
: [
{ value: 'A Fazer', label: 'A Fazer', color: '#71717a' },
{ value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'Concluído', label: 'Concluído', color: '#10b981' },
{ value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' },
]
}
/> />
</div> </div>
</div> </div>

View file

@ -1,63 +1,74 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical } from 'lucide-react' import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical, Edit2, Trash2 } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task' import type { Task, TaskStatus } from '../../types/task'
import { TaskCard } from './TaskCard' import { TaskCard } from './TaskCard'
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils' import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
interface TaskKanbanViewProps { interface TaskKanbanViewProps {
tasks: Task[] tasks: Task[]
taskStatuses?: TaskStatus[]
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
onEditTask: (task: Task) => void onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void onOpenHistory?: (task: Task) => void
onDeleteTask?: (task: Task) => void onDeleteTask?: (task: Task) => void
onAddTaskToStatus: (status: TaskStatus) => void onAddTaskToStatus: (status: string, statusId?: number) => void
onOpenCreateStatus?: () => void
onEditStatus?: (status: TaskStatus) => void
onDeleteStatus?: (status: TaskStatus) => void
activeTaskId?: number | null activeTaskId?: number | null
activeTaskSeconds?: number activeTaskSeconds?: number
hiddenColumns?: TaskStatus[] hiddenColumns?: string[]
showAllColumns?: boolean showAllColumns?: boolean
onHideColumn?: (status: TaskStatus) => void onHideColumn?: (status: string) => void
onUnhideColumn?: (status: TaskStatus) => void onUnhideColumn?: (status: string) => void
} }
export interface ColumnDef { export interface ColumnDef {
status: TaskStatus id?: number
status: string
label: string label: string
icon: React.ElementType icon: React.ElementType
colorClass: string color: string
bgLightClass: string colorClass?: string
borderColor: string bgLightClass?: string
borderColor?: string
rawStatus?: TaskStatus
} }
export const COLUMNS: ColumnDef[] = [ export const DEFAULT_COLUMNS: ColumnDef[] = [
{ {
status: 'NOT_STARTED', status: 'A Fazer',
label: 'A Fazer', label: 'A Fazer',
icon: CircleDot, icon: CircleDot,
color: '#71717a',
colorClass: 'text-muted', colorClass: 'text-muted',
bgLightClass: 'bg-surface-sunken', bgLightClass: 'bg-surface-sunken',
borderColor: 'border-border-soft', borderColor: 'border-border-soft',
}, },
{ {
status: 'IN_PROGRESS', status: 'Em Andamento',
label: 'Em Andamento', label: 'Em Andamento',
icon: Clock, icon: Clock,
color: '#14b8a6',
colorClass: 'text-teal-700 dark:text-teal-400', colorClass: 'text-teal-700 dark:text-teal-400',
bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15', bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15',
borderColor: 'border-teal-600/25 dark:border-teal-500/20', borderColor: 'border-teal-600/25 dark:border-teal-500/20',
}, },
{ {
status: 'COMPLETED', status: 'Concluído',
label: 'Concluído', label: 'Concluído',
icon: CheckCircle2, icon: CheckCircle2,
color: '#10b981',
colorClass: 'text-emerald-700 dark:text-emerald-400', colorClass: 'text-emerald-700 dark:text-emerald-400',
bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15', bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15',
borderColor: 'border-emerald-600/25 dark:border-emerald-500/20', borderColor: 'border-emerald-600/25 dark:border-emerald-500/20',
}, },
{ {
status: 'CANCELED', status: 'Cancelado',
label: 'Cancelado', label: 'Cancelado',
icon: Ban, icon: Ban,
color: '#f43f5e',
colorClass: 'text-rose-700 dark:text-rose-400', colorClass: 'text-rose-700 dark:text-rose-400',
bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15', bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15',
borderColor: 'border-rose-600/25 dark:border-rose-500/20', borderColor: 'border-rose-600/25 dark:border-rose-500/20',
@ -66,12 +77,16 @@ export const COLUMNS: ColumnDef[] = [
export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
tasks, tasks,
taskStatuses = [],
onToggleTimer, onToggleTimer,
onStatusChange, onStatusChange,
onEditTask, onEditTask,
onOpenHistory, onOpenHistory,
onDeleteTask, onDeleteTask,
onAddTaskToStatus, onAddTaskToStatus,
onOpenCreateStatus,
onEditStatus,
onDeleteStatus,
activeTaskId, activeTaskId,
activeTaskSeconds, activeTaskSeconds,
hiddenColumns = [], hiddenColumns = [],
@ -79,20 +94,44 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
onHideColumn, onHideColumn,
onUnhideColumn, onUnhideColumn,
}) => { }) => {
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null) const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
const [columnMenuOpen, setColumnMenuOpen] = useState<TaskStatus | null>(null) const [columnMenuOpen, setColumnMenuOpen] = useState<string | null>(null)
// Build columns dynamically from taskStatuses if available, else use default 4
const columns: ColumnDef[] = React.useMemo(() => {
if (taskStatuses && taskStatuses.length > 0) {
return [...taskStatuses]
.sort((a, b) => a.position - b.position)
.map((s) => {
let icon = Clock
if (s.isCompleted) icon = CheckCircle2
else if (s.isCanceled) icon = Ban
else if (s.isDefault) icon = CircleDot
return {
id: s.id,
status: s.name,
label: s.name,
icon,
color: s.color || '#71717a',
rawStatus: s,
}
})
}
return DEFAULT_COLUMNS
}, [taskStatuses])
// Visible columns based on hidden state unless showAllColumns is active // Visible columns based on hidden state unless showAllColumns is active
const visibleColumns = COLUMNS.filter((col) => { const visibleColumns = columns.filter((col) => {
if (showAllColumns) return true if (showAllColumns) return true
return !hiddenColumns.includes(col.status) return !hiddenColumns.includes(col.status) && (!col.id || !hiddenColumns.includes(String(col.id)))
}) })
const handleDragOver = (e: React.DragEvent, status: TaskStatus) => { const handleDragOver = (e: React.DragEvent, statusKey: string) => {
e.preventDefault() e.preventDefault()
e.dataTransfer.dropEffect = 'move' e.dataTransfer.dropEffect = 'move'
if (dragOverColumn !== status) { if (dragOverColumn !== statusKey) {
setDragOverColumn(status) setDragOverColumn(statusKey)
} }
} }
@ -101,7 +140,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
setDragOverColumn(null) setDragOverColumn(null)
} }
const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => { const handleDrop = (e: React.DragEvent, targetCol: ColumnDef) => {
e.preventDefault() e.preventDefault()
setDragOverColumn(null) setDragOverColumn(null)
const taskIdStr = e.dataTransfer.getData('text/plain') const taskIdStr = e.dataTransfer.getData('text/plain')
@ -109,16 +148,15 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
const taskId = Number(taskIdStr) const taskId = Number(taskIdStr)
const targetTask = tasks.find((t) => t.id === taskId) const targetTask = tasks.find((t) => t.id === taskId)
if (targetTask && targetTask.status !== targetStatus) { if (!targetTask) return
onStatusChange(targetTask, targetStatus)
}
}
const getGridColsClass = (count: number) => { const isSameStatus =
if (count <= 1) return 'grid-cols-1' (targetCol.id && targetTask.taskStatus?.id === targetCol.id) ||
if (count === 2) return 'grid-cols-1 md:grid-cols-2' targetTask.status === targetCol.status
if (count === 3) return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4' if (!isSameStatus) {
onStatusChange(targetTask, targetCol.status, targetCol.id)
}
} }
if (visibleColumns.length === 0) { if (visibleColumns.length === 0) {
@ -133,11 +171,42 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
) )
} }
const isWideBoard = visibleColumns.length > 4
return ( return (
<div className={`grid ${getGridColsClass(visibleColumns.length)} gap-4 items-start pb-6`}> <div
className={
isWideBoard
? 'flex gap-4 overflow-x-auto pb-6 items-start scrollbar-thin'
: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-start pb-6'
}
>
{visibleColumns.map((col) => { {visibleColumns.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.status) const columnTasks = tasks.filter((t) => {
const isHidden = hiddenColumns.includes(col.status) if (col.id && t.taskStatus?.id) {
return t.taskStatus.id === col.id
}
if (col.id && t.status === String(col.id)) {
return true
}
const tStatus = t.status || ''
if (tStatus === col.status || tStatus === col.label) return true
if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) {
return true
}
if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) {
return true
}
if ((col.status === 'Concluído' || col.status === 'COMPLETED') && (tStatus === 'COMPLETED' || tStatus === 'Concluído' || t.taskStatus?.isCompleted)) {
return true
}
if ((col.status === 'Cancelado' || col.status === 'CANCELED') && (tStatus === 'CANCELED' || tStatus === 'Cancelado' || t.taskStatus?.isCanceled)) {
return true
}
return false
})
const isHidden = hiddenColumns.includes(col.status) || (col.id ? hiddenColumns.includes(String(col.id)) : false)
const colTotalSeconds = columnTasks.reduce( const colTotalSeconds = columnTasks.reduce(
(acc, t) => (acc, t) =>
acc + acc +
@ -152,23 +221,28 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
return ( return (
<div <div
key={col.status} key={col.id ? `col-${col.id}` : `col-${col.status}`}
onDragOver={(e) => handleDragOver(e, col.status)} onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)} onDrop={(e) => handleDrop(e, col)}
style={{
borderColor: isOver ? col.color : `${col.color}35`,
}}
className={`flex flex-col rounded-3xl border transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] overflow-hidden bg-surface/60 backdrop-blur-md shadow-xs ${ className={`flex flex-col rounded-3xl border transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] overflow-hidden bg-surface/60 backdrop-blur-md shadow-xs ${
isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : '' isWideBoard ? 'w-80 shrink-0' : 'w-full'
} ${ } ${isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''} ${
isOver isOver ? 'ring-2 ring-brand/30 bg-brand/5' : ''
? 'border-brand ring-2 ring-brand/30 bg-brand/5'
: col.borderColor
}`} }`}
> >
{/* Column Header */} {/* Column Header */}
<div className="shrink-0 flex items-center justify-between p-3.5 border-b border-border-soft bg-surface/40"> <div className="shrink-0 flex items-center justify-between p-3.5 border-b border-border-soft bg-surface/40">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div <div
className={`w-7 h-7 rounded-lg ${col.bgLightClass} flex items-center justify-center shrink-0 ${col.colorClass}`} className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
style={{
backgroundColor: `${col.color}20`,
color: col.color,
}}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
</div> </div>
@ -195,14 +269,14 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button
type="button" type="button"
onClick={() => onAddTaskToStatus(col.status)} onClick={() => onAddTaskToStatus(col.status, col.id)}
title={`Adicionar tarefa em ${col.label}`} title={`Adicionar tarefa em ${col.label}`}
className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors" className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
</button> </button>
{/* Column options menu (hide/unhide) */} {/* Column options menu */}
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@ -226,6 +300,21 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
}} }}
/> />
<div className="absolute right-0 top-8 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left"> <div className="absolute right-0 top-8 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
{col.rawStatus && onEditStatus && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setColumnMenuOpen(null)
onEditStatus(col.rawStatus!)
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-foreground hover:bg-surface-sunken transition-colors"
>
<Edit2 className="w-3.5 h-3.5 text-muted" />
<span>Editar Status</span>
</button>
)}
{isHidden ? ( {isHidden ? (
<button <button
type="button" type="button"
@ -247,12 +336,27 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
onHideColumn?.(col.status) onHideColumn?.(col.status)
setColumnMenuOpen(null) setColumnMenuOpen(null)
}} }}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-rose-400 hover:bg-surface-sunken transition-colors" className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-amber-500 hover:bg-surface-sunken transition-colors"
> >
<EyeOff className="w-3.5 h-3.5" /> <EyeOff className="w-3.5 h-3.5" />
<span>Ocultar coluna</span> <span>Ocultar coluna</span>
</button> </button>
)} )}
{col.rawStatus && onDeleteStatus && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setColumnMenuOpen(null)
onDeleteStatus(col.rawStatus!)
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-rose-400 hover:bg-surface-sunken transition-colors border-t border-border-soft mt-1 pt-1"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Excluir Status</span>
</button>
)}
</div> </div>
</> </>
)} )}
@ -281,7 +385,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p> <p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
<button <button
type="button" type="button"
onClick={() => onAddTaskToStatus(col.status)} onClick={() => onAddTaskToStatus(col.status, col.id)}
className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1" className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
@ -293,6 +397,23 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
</div> </div>
) )
})} })}
{/* Add new column button */}
{onOpenCreateStatus && (
<button
type="button"
onClick={onOpenCreateStatus}
className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-border-soft hover:border-brand/50 hover:bg-brand/5 text-muted hover:text-brand transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] w-72 shrink-0 p-6 group cursor-pointer"
>
<div className="w-12 h-12 rounded-2xl bg-surface-raised border border-border-soft flex items-center justify-center text-muted group-hover:text-brand group-hover:border-brand/40 group-hover:scale-110 transition-all mb-3 shadow-xs">
<Plus className="w-6 h-6" />
</div>
<span className="font-semibold text-sm">Novo Status</span>
<span className="text-xs text-muted text-center mt-1">
Adicione uma nova coluna ao quadro da sua organização
</span>
</button>
)}
</div> </div>
) )
} }

View file

@ -8,8 +8,9 @@ import { canDeleteTask } from '../../lib/taskPermissions'
interface TaskListViewProps { interface TaskListViewProps {
tasks: Task[] tasks: Task[]
taskStatuses?: TaskStatus[]
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void
onEditTask: (task: Task) => void onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void onOpenHistory?: (task: Task) => void
onDeleteTask?: (task: Task) => void onDeleteTask?: (task: Task) => void
@ -20,6 +21,7 @@ interface TaskListViewProps {
export const TaskListView: React.FC<TaskListViewProps> = ({ export const TaskListView: React.FC<TaskListViewProps> = ({
tasks, tasks,
taskStatuses = [],
onToggleTimer, onToggleTimer,
onStatusChange, onStatusChange,
onEditTask, onEditTask,
@ -29,6 +31,24 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
activeTaskSeconds, activeTaskSeconds,
}) => { }) => {
const { user, activeOrg } = useAuth() const { user, activeOrg } = useAuth()
const statusOptions = React.useMemo(() => {
if (taskStatuses && taskStatuses.length > 0) {
return [...taskStatuses]
.sort((a, b) => a.position - b.position)
.map((s) => ({
value: s.name,
label: s.name,
color: s.color || '#71717a',
}))
}
return [
{ value: 'A Fazer', label: 'A Fazer', color: '#71717a' },
{ value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'Concluído', label: 'Concluído', color: '#10b981' },
{ value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' },
]
}, [taskStatuses])
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null) const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
return ( return (
@ -151,14 +171,12 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
<div className="w-36"> <div className="w-36">
<CustomSelect <CustomSelect
size="sm" size="sm"
value={task.status} value={task.taskStatus?.name || task.status}
onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)} onChange={(newStatus) => {
options={[ const matched = taskStatuses.find((s) => s.name === newStatus)
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, onStatusChange(task, newStatus, matched?.id)
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, }}
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, options={statusOptions}
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
]}
/> />
</div> </div>
</td> </td>

View file

@ -1,5 +1,4 @@
import { authService } from '../services/authService' import { authService } from '../services/authService'
import type { TaskStatus } from '../types/task'
export type DashboardWidgetId = export type DashboardWidgetId =
| 'kpis' | 'kpis'
@ -110,7 +109,7 @@ export interface UserPreferences {
targetHours: number targetHours: number
} }
kanban?: { kanban?: {
hiddenColumns?: TaskStatus[] hiddenColumns?: string[]
} }
} }

View file

@ -333,7 +333,8 @@ export const DashboardPage: React.FC = () => {
const handleModalSubmit = async (taskData: { const handleModalSubmit = async (taskData: {
title: string title: string
description: string description: string
status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED' status: string
statusId?: number
projectId?: number projectId?: number
categoryId?: number categoryId?: number
assigneeUserId?: number assigneeUserId?: number
@ -345,6 +346,7 @@ export const DashboardPage: React.FC = () => {
title: taskData.title, title: taskData.title,
description: taskData.description, description: taskData.description,
status: taskData.status, status: taskData.status,
statusId: taskData.statusId,
projectId: taskData.projectId, projectId: taskData.projectId,
categoryId: taskData.categoryId, categoryId: taskData.categoryId,
assigneeUserId: taskData.assigneeUserId, assigneeUserId: taskData.assigneeUserId,

View 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>
)
}

View file

@ -9,23 +9,25 @@ import {
RefreshCw, RefreshCw,
Folder, Folder,
Users, Users,
User,
CheckCircle2,
Clock,
Sparkles, Sparkles,
AlertCircle, AlertCircle,
CircleDot, CircleDot,
Ban,
Eye, Eye,
EyeOff, EyeOff,
SlidersHorizontal, SlidersHorizontal,
Trash2,
Check,
X,
Loader2,
User as UserIcon,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext' import { useTimer } from '../contexts/TimerContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService' import { projectService } from '../services/projectService'
import { taskStatusService } from '../services/taskStatusService'
import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task' import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task'
import { TaskKanbanView, COLUMNS } from '../components/tasks/TaskKanbanView' import { TaskKanbanView, DEFAULT_COLUMNS } from '../components/tasks/TaskKanbanView'
import { TaskListView } from '../components/tasks/TaskListView' import { TaskListView } from '../components/tasks/TaskListView'
import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal' import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal'
@ -36,6 +38,19 @@ import {
savePreferencesToBackend, savePreferencesToBackend,
} from '../lib/userPreferences' } from '../lib/userPreferences'
const COLOR_PRESETS = [
'#71717a',
'#0f766e',
'#10b981',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#ec4899',
'#f43f5e',
'#f59e0b',
'#64748b',
]
export const TasksPage: React.FC = () => { export const TasksPage: React.FC = () => {
const { activeOrg, user } = useAuth() const { activeOrg, user } = useAuth()
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer() const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
@ -47,18 +62,19 @@ export const TasksPage: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [taskStatuses, setTaskStatuses] = useState<TaskStatus[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban') const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban')
// Kanban column visibility preferences // Kanban column visibility preferences
const [hiddenColumns, setHiddenColumns] = useState<TaskStatus[]>([]) const [hiddenColumns, setHiddenColumns] = useState<string[]>([])
const [showAllColumns, setShowAllColumns] = useState(false) const [showAllColumns, setShowAllColumns] = useState(false)
const [showColumnSettings, setShowColumnSettings] = useState(false) const [showColumnSettings, setShowColumnSettings] = useState(false)
// Filters // Filters
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedStatus, setSelectedStatus] = useState<TaskStatus | 'ALL'>('ALL') const [selectedStatus, setSelectedStatus] = useState<string | 'ALL'>('ALL')
const [selectedProjectId, setSelectedProjectId] = useState<number | 'ALL'>('ALL') const [selectedProjectId, setSelectedProjectId] = useState<number | 'ALL'>('ALL')
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe) // Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
@ -86,13 +102,28 @@ export const TasksPage: React.FC = () => {
// Modal state // Modal state
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED') const [modalInitialStatus, setModalInitialStatus] = useState<string>('A Fazer')
const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details') const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details')
const [editingTask, setEditingTask] = useState<Task | null>(null) const [editingTask, setEditingTask] = useState<Task | null>(null)
const [isAiModalOpen, setIsAiModalOpen] = useState(false) const [isAiModalOpen, setIsAiModalOpen] = useState(false)
const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null) const [aiDraftTask, setAiDraftTask] = useState<ParsedAiTask | null>(null)
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
// Status Quick Create / Edit Modal State
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false)
const [editingStatus, setEditingStatus] = useState<TaskStatus | null>(null)
const [statusName, setStatusName] = useState('')
const [statusColor, setStatusColor] = useState(COLOR_PRESETS[0])
const [statusIsDefault, setStatusIsDefault] = useState(false)
const [statusIsCompleted, setStatusIsCompleted] = useState(false)
const [statusIsCanceled, setStatusIsCanceled] = useState(false)
const [statusSubmitting, setStatusSubmitting] = useState(false)
// Status Delete Modal State
const [deletingStatus, setDeletingStatus] = useState<TaskStatus | null>(null)
const [moveToStatusId, setMoveToStatusId] = useState<number | undefined>(undefined)
const [deletingStatusLoading, setDeletingStatusLoading] = useState(false)
// Load user preferences for hidden columns from backend // Load user preferences for hidden columns from backend
useEffect(() => { useEffect(() => {
if (!user?.email) return if (!user?.email) return
@ -112,7 +143,7 @@ export const TasksPage: React.FC = () => {
// Save hidden columns to backend // Save hidden columns to backend
const updateHiddenColumns = useCallback( const updateHiddenColumns = useCallback(
(newHidden: TaskStatus[]) => { (newHidden: string[]) => {
setHiddenColumns(newHidden) setHiddenColumns(newHidden)
if (!user?.email) return if (!user?.email) return
const current = getLocalPreferences(user.email) const current = getLocalPreferences(user.email)
@ -131,7 +162,7 @@ export const TasksPage: React.FC = () => {
) )
const handleHideColumn = useCallback( const handleHideColumn = useCallback(
(status: TaskStatus) => { (status: string) => {
if (!hiddenColumns.includes(status)) { if (!hiddenColumns.includes(status)) {
updateHiddenColumns([...hiddenColumns, status]) updateHiddenColumns([...hiddenColumns, status])
} }
@ -140,7 +171,7 @@ export const TasksPage: React.FC = () => {
) )
const handleUnhideColumn = useCallback( const handleUnhideColumn = useCallback(
(status: TaskStatus) => { (status: string) => {
updateHiddenColumns(hiddenColumns.filter((s) => s !== status)) updateHiddenColumns(hiddenColumns.filter((s) => s !== status))
}, },
[hiddenColumns, updateHiddenColumns] [hiddenColumns, updateHiddenColumns]
@ -152,18 +183,20 @@ export const TasksPage: React.FC = () => {
const activeOrgId = activeOrg?.id const activeOrgId = activeOrg?.id
// Load tasks & projects for current organization // Load tasks, projects & statuses for current organization
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!activeOrgId) return if (!activeOrgId) return
try { try {
setRefreshing(true) setRefreshing(true)
const [tasksRes, projsRes] = await Promise.all([ const [tasksRes, projsRes, statusesRes] = await Promise.all([
taskService.listTasks({ organizationId: activeOrgId }), taskService.listTasks({ organizationId: activeOrgId }),
projectService.listProjects(activeOrgId).catch(() => []), projectService.listProjects(activeOrgId).catch(() => []),
taskStatusService.listStatuses(activeOrgId).catch(() => []),
]) ])
setTasks(tasksRes || []) setTasks(tasksRes || [])
setProjects(projsRes || []) setProjects(projsRes || [])
setTaskStatuses(statusesRes || [])
syncWithTasksRef.current(tasksRes || []) syncWithTasksRef.current(tasksRes || [])
} catch (err) { } catch (err) {
console.error('Failed to load tasks', err) console.error('Failed to load tasks', err)
@ -192,12 +225,38 @@ export const TasksPage: React.FC = () => {
// Scope tasks based on viewScope // Scope tasks based on viewScope
const scopeTasks = viewScope === 'mine' ? myTasks : tasks const scopeTasks = viewScope === 'mine' ? myTasks : tasks
// Effective columns for preferences & board
const effectiveColumns = useMemo(() => {
if (taskStatuses && taskStatuses.length > 0) {
return [...taskStatuses]
.sort((a, b) => a.position - b.position)
.map((s) => ({
id: s.id,
status: s.name,
label: s.name,
color: s.color || '#71717a',
rawStatus: s,
}))
}
return DEFAULT_COLUMNS
}, [taskStatuses])
// Filter tasks client-side for ultra-responsive search // Filter tasks client-side for ultra-responsive search
const filteredTasks = useMemo(() => { const filteredTasks = useMemo(() => {
return scopeTasks.filter((task) => { return scopeTasks.filter((task) => {
// Status filter // Status filter
if (selectedStatus !== 'ALL' && task.status !== selectedStatus) { if (selectedStatus !== 'ALL') {
return false 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 // Project filter
if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) { if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) {
@ -241,14 +300,24 @@ export const TasksPage: React.FC = () => {
} }
// Status change handler // Status change handler
const handleStatusChange = async (task: Task, newStatus: TaskStatus) => { const handleStatusChange = async (task: Task, newStatus: string, newStatusId?: number) => {
setActionError(null) setActionError(null)
const matchedStatus = taskStatuses.find((s) => s.id === newStatusId || s.name === newStatus)
try { try {
// Optimistic update // Optimistic update
setTasks((prev) => setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, status: newStatus } : t)) prev.map((t) =>
t.id === task.id
? {
...t,
status: newStatus,
taskStatus: matchedStatus || t.taskStatus,
}
: t
)
) )
const updated = await taskService.updateStatus(task.id, newStatus) const updated = await taskService.updateStatus(task.id, newStatus, newStatusId)
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t))) setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to update status', err) console.error('Failed to update status', err)
@ -261,7 +330,8 @@ export const TasksPage: React.FC = () => {
const handleModalSubmit = async (taskData: { const handleModalSubmit = async (taskData: {
title: string title: string
description: string description: string
status: TaskStatus status: string
statusId?: number
projectId?: number projectId?: number
categoryId?: number categoryId?: number
assigneeUserId?: number assigneeUserId?: number
@ -276,6 +346,7 @@ export const TasksPage: React.FC = () => {
title: taskData.title, title: taskData.title,
description: taskData.description, description: taskData.description,
status: taskData.status, status: taskData.status,
statusId: taskData.statusId,
projectId: taskData.projectId, projectId: taskData.projectId,
categoryId: taskData.categoryId, categoryId: taskData.categoryId,
assigneeUserId: taskData.assigneeUserId, assigneeUserId: taskData.assigneeUserId,
@ -291,6 +362,7 @@ export const TasksPage: React.FC = () => {
title: taskData.title, title: taskData.title,
description: taskData.description, description: taskData.description,
status: taskData.status, status: taskData.status,
statusId: taskData.statusId,
projectId: taskData.projectId, projectId: taskData.projectId,
categoryId: taskData.categoryId, categoryId: taskData.categoryId,
assigneeUserId: taskData.assigneeUserId, assigneeUserId: taskData.assigneeUserId,
@ -301,7 +373,7 @@ export const TasksPage: React.FC = () => {
} }
} }
const handleOpenCreateModal = (initialStatus: TaskStatus = 'NOT_STARTED') => { const handleOpenCreateModal = (initialStatus: string = 'A Fazer') => {
setEditingTask(null) setEditingTask(null)
setModalInitialStatus(initialStatus) setModalInitialStatus(initialStatus)
setModalInitialTab('details') setModalInitialTab('details')
@ -343,11 +415,80 @@ export const TasksPage: React.FC = () => {
[activeTask, stopTimer, editingTask] [activeTask, stopTimer, editingTask]
) )
// Summary counts based on active scope // Status modal handlers
const notStartedCount = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length const handleOpenStatusModal = (st: TaskStatus | null) => {
const inProgressCount = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length if (st) {
const completedCount = scopeTasks.filter((t) => t.status === 'COMPLETED').length setEditingStatus(st)
const canceledCount = scopeTasks.filter((t) => t.status === 'CANCELED').length setStatusName(st.name)
setStatusColor(st.color || COLOR_PRESETS[0])
setStatusIsDefault(Boolean(st.isDefault))
setStatusIsCompleted(Boolean(st.isCompleted))
setStatusIsCanceled(Boolean(st.isCanceled))
} else {
setEditingStatus(null)
setStatusName('')
setStatusColor(COLOR_PRESETS[0])
setStatusIsDefault(false)
setStatusIsCompleted(false)
setStatusIsCanceled(false)
}
setIsStatusModalOpen(true)
}
const handleSaveStatus = async (e: React.FormEvent) => {
e.preventDefault()
if (!activeOrgId || !statusName.trim()) return
setStatusSubmitting(true)
try {
if (editingStatus) {
await taskStatusService.updateStatus(activeOrgId, editingStatus.id, {
name: statusName.trim(),
color: statusColor,
isDefault: statusIsDefault,
isCompleted: statusIsCompleted,
isCanceled: statusIsCanceled,
})
} else {
await taskStatusService.createStatus(activeOrgId, {
name: statusName.trim(),
color: statusColor,
isDefault: statusIsDefault,
isCompleted: statusIsCompleted,
isCanceled: statusIsCanceled,
})
}
setIsStatusModalOpen(false)
const updatedStatuses = await taskStatusService.listStatuses(activeOrgId)
setTaskStatuses(updatedStatuses || [])
} catch (err: unknown) {
console.error(err)
setActionError(err instanceof Error ? err.message : 'Falha ao salvar status.')
} finally {
setStatusSubmitting(false)
}
}
const handleOpenDeleteStatusModal = (st: TaskStatus) => {
const others = taskStatuses.filter((s) => s.id !== st.id)
setMoveToStatusId(others.length > 0 ? others[0].id : undefined)
setDeletingStatus(st)
}
const handleConfirmDeleteStatus = async () => {
if (!activeOrgId || !deletingStatus) return
setDeletingStatusLoading(true)
try {
await taskStatusService.deleteStatus(activeOrgId, deletingStatus.id, moveToStatusId)
setDeletingStatus(null)
loadData()
} catch (err: unknown) {
console.error(err)
setActionError(err instanceof Error ? err.message : 'Falha ao excluir status.')
} finally {
setDeletingStatusLoading(false)
}
}
return ( return (
<div className="space-y-6 max-w-7xl mx-auto"> <div className="space-y-6 max-w-7xl mx-auto">
@ -358,124 +499,122 @@ export const TasksPage: React.FC = () => {
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-brand via-brand-strong to-accent bg-clip-text text-transparent"> <h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-brand via-brand-strong to-accent bg-clip-text text-transparent">
Quadro de Tarefas Quadro de Tarefas
</h1> </h1>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25 shadow-xs"> <span className="text-[11px] font-bold px-2 py-0.5 rounded-full bg-brand/10 text-brand border border-brand/20">
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
{activeOrg?.name || 'Workspace'} {activeOrg?.name || 'Workspace'}
</span> </span>
</div> </div>
<p className="text-xs text-muted"> <p className="text-xs sm:text-sm text-muted">
Planeje, execute e rastreie o tempo investido em cada entrega da equipe. Monitore seu fluxo ágil, lance tempos de dedicação e entregue valor contínuo.
</p> </p>
</div> </div>
{/* View Switcher & New Task Button */} {/* View Mode & Actions */}
<div className="flex items-center gap-2.5 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* Refresh */} {/* Refresh button */}
<button <button
type="button" type="button"
onClick={loadData} onClick={loadData}
disabled={refreshing} disabled={refreshing}
title="Recarregar tarefas" className="p-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-muted hover:text-foreground transition-colors disabled:opacity-50"
className="p-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-panel disabled:opacity-50" title="Atualizar tarefas"
> >
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} /> <RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
</button> </button>
{/* Kanban / List Toggle */} {/* View toggle (Kanban vs List) */}
<div className="flex items-center p-1 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel"> <div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft">
<button <button
type="button" type="button"
onClick={() => setViewMode('kanban')} onClick={() => setViewMode('kanban')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${ className={`p-1.5 rounded-lg transition-all ${
viewMode === 'kanban' viewMode === 'kanban'
? 'bg-brand text-white shadow-md shadow-brand/20' ? 'bg-brand text-white shadow-xs'
: 'text-muted hover:text-foreground' : 'text-muted hover:text-foreground'
}`} }`}
title="Visualização em Quadro (Kanban)"
> >
<LayoutGrid className="w-3.5 h-3.5" /> <LayoutGrid className="w-4 h-4" />
<span>Kanban</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${ className={`p-1.5 rounded-lg transition-all ${
viewMode === 'list' viewMode === 'list'
? 'bg-brand text-white shadow-md shadow-brand/20' ? 'bg-brand text-white shadow-xs'
: 'text-muted hover:text-foreground' : 'text-muted hover:text-foreground'
}`} }`}
title="Visualização em Lista"
> >
<ListIcon className="w-3.5 h-3.5" /> <ListIcon className="w-4 h-4" />
<span>Lista</span>
</button> </button>
</div> </div>
{/* Kanban Columns Visibility Control */} {/* Kanban Columns Settings (Only in Kanban Mode) */}
{viewMode === 'kanban' && ( {viewMode === 'kanban' && (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setShowColumnSettings(!showColumnSettings)} onClick={() => setShowColumnSettings(!showColumnSettings)}
title="Gerenciar colunas do Kanban" className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-semibold transition-all ${
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-medium transition-all shadow-panel ${
hiddenColumns.length > 0 hiddenColumns.length > 0
? 'border-brand/40 bg-brand/10 text-brand' ? 'border-amber-500/50 bg-amber-500/10 text-amber-500 ring-1 ring-amber-500/30'
: 'border-border-soft bg-surface/90 text-muted hover:text-foreground hover:bg-surface-raised' : 'border-border-soft bg-surface text-muted hover:text-foreground hover:bg-surface-raised'
}`} }`}
title="Personalizar visualização das colunas"
> >
<SlidersHorizontal className="w-3.5 h-3.5" /> <SlidersHorizontal className="w-3.5 h-3.5" />
<span>Colunas</span> <span className="hidden sm:inline">Colunas</span>
{hiddenColumns.length > 0 && ( {hiddenColumns.length > 0 && (
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-brand text-white font-bold"> <span className="w-4 h-4 rounded-full bg-amber-500 text-black text-[10px] font-bold flex items-center justify-center">
{hiddenColumns.length} oculta{hiddenColumns.length > 1 ? 's' : ''} {hiddenColumns.length}
</span> </span>
)} )}
</button> </button>
{/* Column Settings Dropdown */}
{showColumnSettings && ( {showColumnSettings && (
<> <>
<div <div
className="fixed inset-0 z-40" className="fixed inset-0 z-40"
onClick={() => setShowColumnSettings(false)} onClick={() => setShowColumnSettings(false)}
/> />
<div className="absolute right-0 top-11 z-50 w-64 rounded-2xl border border-border bg-surface-raised p-3 shadow-2xl space-y-2.5"> <div className="absolute right-0 top-11 z-50 w-72 rounded-2xl border border-border bg-surface-raised p-3 shadow-2xl space-y-3 animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center justify-between pb-2 border-b border-border"> <div className="flex items-center justify-between border-b border-border-soft pb-2">
<span className="text-xs font-bold text-foreground">Visibilidade de Colunas</span> <div className="flex items-center gap-1.5">
{hiddenColumns.length > 0 && ( <SlidersHorizontal className="w-3.5 h-3.5 text-brand" />
<button <h4 className="text-xs font-bold text-foreground">Colunas do Quadro</h4>
type="button" </div>
onClick={handleUnhideAllColumns} <span className="text-[10px] text-muted font-medium">
className="text-[11px] text-brand hover:underline font-medium" {effectiveColumns.length - hiddenColumns.length} de {effectiveColumns.length} ativas
> </span>
Restaurar todas
</button>
)}
</div> </div>
{/* Toggle show all columns temporarily */} {/* Master toggle */}
<div className="flex items-center justify-between p-2 rounded-xl bg-surface border border-border-soft"> <div className="flex items-center justify-between text-xs py-1">
<div className="flex items-center gap-2"> <span className="text-muted text-[11px]">Exibir todas as colunas:</span>
{showAllColumns ? ( <button
<Eye className="w-3.5 h-3.5 text-brand" /> type="button"
) : ( onClick={() => {
<EyeOff className="w-3.5 h-3.5 text-muted" /> if (showAllColumns) {
)} setShowAllColumns(false)
<span className="text-xs font-medium text-foreground">Mostrar ocultas</span> } else {
</div> handleUnhideAllColumns()
<input setShowAllColumns(false)
type="checkbox" }
checked={showAllColumns} }}
onChange={(e) => setShowAllColumns(e.target.checked)} className="text-xs font-semibold text-brand hover:underline"
className="w-4 h-4 rounded text-brand border-border focus:ring-brand" >
/> {hiddenColumns.length === 0 ? 'Todas visíveis' : 'Reexibir todas'}
</button>
</div> </div>
{/* Individual columns list */} {/* Individual columns list */}
<div className="space-y-1"> <div className="space-y-1 max-h-60 overflow-y-auto">
{COLUMNS.map((col) => { {effectiveColumns.map((col) => {
const isHidden = hiddenColumns.includes(col.status) const isHidden = hiddenColumns.includes(col.status)
return ( return (
<button <button
key={col.status} key={col.id ? `col-${col.id}` : col.status}
type="button" type="button"
onClick={() => { onClick={() => {
if (isHidden) { if (isHidden) {
@ -490,22 +629,21 @@ export const TasksPage: React.FC = () => {
: 'bg-surface hover:bg-surface-sunken text-foreground' : 'bg-surface hover:bg-surface-sunken text-foreground'
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span <span
className={`w-2 h-2 rounded-full ${ className="w-2.5 h-2.5 rounded-full shrink-0"
isHidden ? 'bg-zinc-500' : 'bg-brand' style={{ backgroundColor: col.color || '#71717a' }}
}`}
/> />
<span>{col.label}</span> <span className="truncate">{col.label}</span>
</div> </div>
{isHidden ? ( {isHidden ? (
<span className="flex items-center gap-1 text-[11px] text-amber-500 font-medium"> <span className="flex items-center gap-1 text-[11px] text-amber-500 font-medium shrink-0">
<EyeOff className="w-3.5 h-3.5" /> <EyeOff className="w-3.5 h-3.5" />
<span>Oculta</span> <span>Oculta</span>
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1 text-[11px] text-brand font-medium"> <span className="flex items-center gap-1 text-[11px] text-brand font-medium shrink-0">
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
<span>Visível</span> <span>Visível</span>
</span> </span>
@ -514,6 +652,20 @@ export const TasksPage: React.FC = () => {
) )
})} })}
</div> </div>
<div className="pt-2 border-t border-border-soft flex items-center justify-between">
<button
type="button"
onClick={() => {
setShowColumnSettings(false)
handleOpenStatusModal(null)
}}
className="text-xs text-brand hover:underline font-semibold flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" />
<span>Novo Status</span>
</button>
</div>
</div> </div>
</> </>
)} )}
@ -536,7 +688,8 @@ export const TasksPage: React.FC = () => {
type="button" type="button"
onClick={() => { onClick={() => {
setAiDraftTask(null) setAiDraftTask(null)
handleOpenCreateModal('NOT_STARTED') const defaultCol = taskStatuses.find((s) => s.isDefault)?.name || 'A Fazer'
handleOpenCreateModal(defaultCol)
}} }}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0" className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0"
> >
@ -579,8 +732,8 @@ export const TasksPage: React.FC = () => {
: 'text-muted hover:text-foreground' : 'text-muted hover:text-foreground'
}`} }`}
> >
<User className="w-3.5 h-3.5" /> <UserIcon className="w-3.5 h-3.5" />
<span>Individual (Minhas Tarefas)</span> <span>Minhas Tarefas</span>
<span <span
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${ className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted' viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
@ -589,6 +742,7 @@ export const TasksPage: React.FC = () => {
{myTasks.length} {myTasks.length}
</span> </span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleScopeChange('team')} onClick={() => handleScopeChange('team')}
@ -646,7 +800,7 @@ export const TasksPage: React.FC = () => {
)} )}
</div> </div>
{/* Status Statistics (Icon + Number) */} {/* Status Statistics (Dynamic Pills) */}
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap py-1"> <div className="flex items-center gap-1.5 sm:gap-2 flex-wrap py-1">
{/* Total / All */} {/* Total / All */}
<button <button
@ -663,65 +817,51 @@ export const TasksPage: React.FC = () => {
<span className="font-mono text-xs">{scopeTasks.length}</span> <span className="font-mono text-xs">{scopeTasks.length}</span>
</button> </button>
{/* A Fazer (NOT_STARTED) */} {/* Dynamic Status Pills */}
<button {effectiveColumns.map((col) => {
type="button" const count = scopeTasks.filter((t) => {
onClick={() => setSelectedStatus(selectedStatus === 'NOT_STARTED' ? 'ALL' : 'NOT_STARTED')} if (col.id && t.taskStatus?.id) return t.taskStatus.id === col.id
title="A Fazer" const tStatus = t.status || ''
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 ${ if (tStatus === col.status || tStatus === col.label) return true
selectedStatus === 'NOT_STARTED' if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) return true
? 'bg-zinc-500/15 border-zinc-500/40 text-foreground ring-1 ring-zinc-500/30' if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) return true
: 'bg-surface-raised/60 border-border-soft text-muted hover:text-foreground hover:bg-surface-raised' 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
<CircleDot className="w-3.5 h-3.5 text-muted shrink-0" /> }).length
<span className="font-mono text-xs text-foreground">{notStartedCount}</span>
</button>
{/* Em Andamento (IN_PROGRESS) */} const isSelected = selectedStatus === col.status
<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>
{/* Concluído (COMPLETED) */} return (
<button <button
type="button" key={col.id ? `pill-${col.id}` : `pill-${col.status}`}
onClick={() => setSelectedStatus(selectedStatus === 'COMPLETED' ? 'ALL' : 'COMPLETED')} type="button"
title="Concluído" onClick={() => setSelectedStatus(isSelected ? 'ALL' : col.status)}
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 ${ title={col.label}
selectedStatus === 'COMPLETED' style={
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/30' isSelected
: 'bg-surface-raised/60 border-border-soft text-emerald-700 dark:text-emerald-400 hover:bg-surface-raised' ? {
}`} backgroundColor: `${col.color}25`,
> borderColor: col.color,
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-700 dark:text-emerald-400 shrink-0" /> color: col.color,
<span className="font-mono text-xs">{completedCount}</span> }
</button> : undefined
}
{/* Cancelado (CANCELED) */} 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 ${
<button isSelected
type="button" ? 'shadow-xs ring-1'
onClick={() => setSelectedStatus(selectedStatus === 'CANCELED' ? 'ALL' : 'CANCELED')} : 'bg-surface-raised/60 border-border-soft text-muted hover:text-foreground hover:bg-surface-raised'
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' <span
? 'bg-rose-500/20 border-rose-500/50 text-rose-700 dark:text-rose-300 ring-1 ring-rose-500/30' className="w-2 h-2 rounded-full shrink-0"
: 'bg-surface-raised/60 border-border-soft text-rose-700 dark:text-rose-400 hover:bg-surface-raised' style={{ backgroundColor: col.color || '#71717a' }}
}`} />
> <span>{col.label}</span>
<Ban className="w-3.5 h-3.5 text-rose-700 dark:text-rose-400 shrink-0" /> <span className="font-mono text-xs">{count}</span>
<span className="font-mono text-xs">{canceledCount}</span> </button>
</button> )
})}
</div> </div>
</div> </div>
@ -750,13 +890,14 @@ export const TasksPage: React.FC = () => {
<div className="w-44"> <div className="w-44">
<CustomSelect <CustomSelect
value={selectedStatus} value={selectedStatus}
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')} onChange={(val) => setSelectedStatus(val as string | 'ALL')}
options={[ options={[
{ value: 'ALL', label: 'Todos os Status' }, { value: 'ALL', label: 'Todos os Status' },
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, ...effectiveColumns.map((c) => ({
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, value: c.status,
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, label: c.label,
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' }, color: c.color,
})),
]} ]}
/> />
</div> </div>
@ -792,7 +933,7 @@ export const TasksPage: React.FC = () => {
</p> </p>
<button <button
type="button" type="button"
onClick={() => handleOpenCreateModal('NOT_STARTED')} onClick={() => handleOpenCreateModal('A Fazer')}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all" className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -802,12 +943,16 @@ export const TasksPage: React.FC = () => {
) : viewMode === 'kanban' ? ( ) : viewMode === 'kanban' ? (
<TaskKanbanView <TaskKanbanView
tasks={filteredTasks} tasks={filteredTasks}
taskStatuses={taskStatuses}
onToggleTimer={handleToggleTimer} onToggleTimer={handleToggleTimer}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal} onEditTask={handleOpenEditModal}
onOpenHistory={handleOpenHistoryModal} onOpenHistory={handleOpenHistoryModal}
onDeleteTask={handleDeleteTask} onDeleteTask={handleDeleteTask}
onAddTaskToStatus={handleOpenCreateModal} onAddTaskToStatus={handleOpenCreateModal}
onOpenCreateStatus={() => handleOpenStatusModal(null)}
onEditStatus={(st) => handleOpenStatusModal(st)}
onDeleteStatus={(st) => handleOpenDeleteStatusModal(st)}
activeTaskId={activeTask?.id} activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds} activeTaskSeconds={activeSeconds}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
@ -818,6 +963,7 @@ export const TasksPage: React.FC = () => {
) : ( ) : (
<TaskListView <TaskListView
tasks={filteredTasks} tasks={filteredTasks}
taskStatuses={taskStatuses}
onToggleTimer={handleToggleTimer} onToggleTimer={handleToggleTimer}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal} onEditTask={handleOpenEditModal}
@ -844,6 +990,7 @@ export const TasksPage: React.FC = () => {
initialTask={editingTask} initialTask={editingTask}
initialTab={modalInitialTab} initialTab={modalInitialTab}
initialDraft={aiDraftTask} initialDraft={aiDraftTask}
taskStatuses={taskStatuses}
/> />
)} )}
@ -856,11 +1003,186 @@ export const TasksPage: React.FC = () => {
onSuccess={(parsed) => { onSuccess={(parsed) => {
setAiDraftTask(parsed) setAiDraftTask(parsed)
setEditingTask(null) setEditingTask(null)
setModalInitialStatus(parsed.status || 'NOT_STARTED') setModalInitialStatus(parsed.status || 'A Fazer')
setIsModalOpen(true) setIsModalOpen(true)
}} }}
/> />
)} )}
{/* Status Modal (Create / Edit from Kanban) */}
{isStatusModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-border-soft pb-3">
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<CircleDot className="w-4 h-4 text-brand" />
<span>{editingStatus ? 'Editar Status' : 'Novo Status do Quadro'}</span>
</h2>
<button
type="button"
onClick={() => setIsStatusModalOpen(false)}
className="w-7 h-7 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised flex items-center justify-center"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSaveStatus} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">Nome do Status</label>
<input
type="text"
required
placeholder="Ex: Em Teste, Homologação, Bloqueado"
value={statusName}
onChange={(e) => setStatusName(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs focus:outline-hidden focus:border-brand"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">Cor da Coluna</label>
<div className="flex items-center gap-2 flex-wrap">
{COLOR_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setStatusColor(preset)}
className={`w-7 h-7 rounded-full border-2 transition-all flex items-center justify-center ${
statusColor === preset ? 'border-foreground scale-110 shadow-xs' : 'border-transparent hover:scale-105'
}`}
style={{ backgroundColor: preset }}
>
{statusColor === preset && <Check className="w-3.5 h-3.5 text-white drop-shadow-md" />}
</button>
))}
</div>
</div>
{/* Behavior Flags */}
<div className="space-y-2 pt-2 border-t border-border-soft">
<label className="block text-xs font-semibold text-foreground mb-1">Comportamento</label>
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={statusIsDefault}
onChange={(e) => setStatusIsDefault(e.target.checked)}
className="w-4 h-4 rounded-md border-border text-brand focus:ring-brand"
/>
<span>Status inicial padrão para novas tarefas</span>
</label>
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={statusIsCompleted}
onChange={(e) => {
setStatusIsCompleted(e.target.checked)
if (e.target.checked) setStatusIsCanceled(false)
}}
className="w-4 h-4 rounded-md border-border text-emerald-500 focus:ring-emerald-500"
/>
<span>Indica que a tarefa foi concluída</span>
</label>
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={statusIsCanceled}
onChange={(e) => {
setStatusIsCanceled(e.target.checked)
if (e.target.checked) setStatusIsCompleted(false)
}}
className="w-4 h-4 rounded-md border-border text-rose-500 focus:ring-rose-500"
/>
<span>Indica que a tarefa foi cancelada</span>
</label>
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
<button
type="button"
onClick={() => setIsStatusModalOpen(false)}
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground hover:bg-surface-raised"
>
Cancelar
</button>
<button
type="submit"
disabled={statusSubmitting || !statusName.trim()}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-accent to-brand text-accent-foreground font-semibold text-xs shadow-md shadow-accent/20 hover:opacity-95 disabled:opacity-50"
>
{statusSubmitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
<span>{editingStatus ? 'Salvar Alterações' : 'Criar Status'}</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* Modal Excluir Status com Reatribuição */}
{deletingStatus && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div className="w-full max-w-md rounded-3xl border border-border bg-surface p-6 shadow-2xl space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-rose-500/15 text-rose-500 flex items-center justify-center shrink-0">
<Trash2 className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-sm text-foreground">Excluir Status "{deletingStatus.name}"?</h3>
<p className="text-xs text-muted">Esta ação remove o status e a coluna correspondente no Kanban.</p>
</div>
</div>
{(() => {
const otherStatuses = taskStatuses.filter((s) => s.id !== deletingStatus.id)
return (
<div className="space-y-3 pt-2">
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-500">
Se houver tarefas vinculadas a este status, selecione para qual coluna elas serão movidas:
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">Mover tarefas para:</label>
<select
value={moveToStatusId}
onChange={(e) => setMoveToStatusId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs"
>
{otherStatuses.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
</div>
)
})()}
<div className="flex items-center justify-end gap-2 pt-3 border-t border-border-soft">
<button
type="button"
onClick={() => setDeletingStatus(null)}
className="px-3.5 py-2 rounded-xl border border-border-soft text-xs text-muted hover:text-foreground"
>
Cancelar
</button>
<button
type="button"
disabled={deletingStatusLoading}
onClick={handleConfirmDeleteStatus}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-600 text-white font-semibold text-xs shadow-md shadow-rose-600/20 hover:bg-rose-700 disabled:opacity-50"
>
{deletingStatusLoading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
<span>Excluir Status</span>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View file

@ -1,5 +1,5 @@
import { apiFetch } from '../lib/api' import { apiFetch } from '../lib/api'
import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task' import type { Task, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task'
function buildQueryParams(filter?: ListTasksFilter): string { function buildQueryParams(filter?: ListTasksFilter): string {
if (!filter) return '' if (!filter) return ''
@ -57,9 +57,14 @@ export const taskService = {
}) })
}, },
async updateStatus(id: number, status: TaskStatus): Promise<Task> { async updateStatus(id: number, status: string, statusId?: number): Promise<Task> {
if (statusId) {
return apiFetch<Task>(`/tasks/${id}/status/${statusId}`, {
method: 'PUT',
})
}
return apiFetch<Task>(`/tasks/${id}/status`, { return apiFetch<Task>(`/tasks/${id}/status`, {
method: 'PATCH', method: 'PUT',
body: status, body: status,
}) })
}, },

View 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,
})
},
}

View file

@ -1,4 +1,16 @@
export type TaskStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'; export type LegacyTaskStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED';
export interface TaskStatus {
id: number;
name: string;
color: string;
position: number;
isDefault?: boolean;
isCompleted?: boolean;
isCanceled?: boolean;
organizationId?: number;
taskCount?: number;
}
export interface TaskHistoryItem { export interface TaskHistoryItem {
id: number; id: number;
@ -32,7 +44,8 @@ export interface Task {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
status: TaskStatus; status: string;
taskStatus?: TaskStatus;
project?: Project; project?: Project;
category?: Category; category?: Category;
user?: TaskUser; user?: TaskUser;
@ -51,7 +64,8 @@ export interface TaskHistoryItemInput {
export interface CreateTaskPayload { export interface CreateTaskPayload {
title: string; title: string;
description?: string; description?: string;
status?: TaskStatus; status?: string;
statusId?: number;
organizationId: number; organizationId: number;
projectId?: number; projectId?: number;
categoryId?: number; categoryId?: number;
@ -67,13 +81,13 @@ export interface ParsedAiTask {
categoryId?: number; categoryId?: number;
assigneeUserId?: number; assigneeUserId?: number;
externalUrl?: string; externalUrl?: string;
status?: TaskStatus; status?: string;
} }
export interface ListTasksFilter { export interface ListTasksFilter {
organizationId?: number; organizationId?: number;
text?: string; text?: string;
status?: TaskStatus[]; status?: string[];
projectId?: number[]; projectId?: number[];
categoryId?: number[]; categoryId?: number[];
userId?: number[]; userId?: number[];