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

View file

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

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.members.ListOrganizationMembersUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -33,8 +34,9 @@ public class OrganizationUseCaseConfig {
@Produces
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository);
UserOrganizationRepository userOrganizationRepository,
TaskStatusRepository taskStatusRepository) {
return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository, taskStatusRepository);
}
@Produces

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

View file

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

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

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

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 net.beetechgroup.beetask.entities.task.TaskStatus;
public record CreateTaskRequest(
String title,
String description,
TaskStatus status,
String status,
Long statusId,
Long organizationId,
Long projectId,
Long categoryId,

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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;
import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
public record CreateTaskInput(
String title,
String description,
TaskStatus status,
String status,
Long statusId,
Long organizationId,
Long projectId,
Long categoryId,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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 {
id: number;
@ -32,7 +44,8 @@ export interface Task {
id: number;
title: string;
description?: string;
status: TaskStatus;
status: string;
taskStatus?: TaskStatus;
project?: Project;
category?: Category;
user?: TaskUser;
@ -51,7 +64,8 @@ export interface TaskHistoryItemInput {
export interface CreateTaskPayload {
title: string;
description?: string;
status?: TaskStatus;
status?: string;
statusId?: number;
organizationId: number;
projectId?: number;
categoryId?: number;
@ -67,13 +81,13 @@ export interface ParsedAiTask {
categoryId?: number;
assigneeUserId?: number;
externalUrl?: string;
status?: TaskStatus;
status?: string;
}
export interface ListTasksFilter {
organizationId?: number;
text?: string;
status?: TaskStatus[];
status?: string[];
projectId?: number[];
categoryId?: number[];
userId?: number[];