feat: adicionar categorias para tasks (#23)

Adiciona conceito de Categoria por organização (nome, cor e ícone lucide),
com CRUD restrito a OWNER/ADMIN. Tasks ganham categoria opcional, filtros
multiselect de status e categoria nas listagens, badges nos cards, e
gráficos de pizza de tempo por categoria nos dashboards pessoal, da
organização e no modal de detalhes do membro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-15 16:03:04 -04:00
parent 16529fafa5
commit 2fdb375462
71 changed files with 1837 additions and 97 deletions

View file

@ -0,0 +1,62 @@
package net.beetechgroup.beetask.entities;
import java.util.Objects;
import net.beetechgroup.beetask.entities.organization.Organization;
public class Category {
private Long id;
private String name;
private String color;
private String icon;
private 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 String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public Organization getOrganization() {
return organization;
}
public void setOrganization(Organization organization) {
this.organization = organization;
}
@Override
public String toString() {
return "Category{" +
"id=" + id +
", name='" + name + '\'' +
", organization=" + (Objects.nonNull(organization) ? organization.getName() : "null") +
'}';
}
}

View file

@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.User;
@ -14,6 +15,7 @@ public class Task {
private String description; private String description;
private TaskStatus status; private TaskStatus status;
private Project project; private Project project;
private Category category;
private User user; private User user;
private LocalDateTime finishedAt; private LocalDateTime finishedAt;
private List<TaskHistoryItem> history = new ArrayList<>(); private List<TaskHistoryItem> history = new ArrayList<>();
@ -74,6 +76,14 @@ public class Task {
this.project = project; this.project = project;
} }
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public User getUser() { public User getUser() {
return user; return user;
} }

View file

@ -0,0 +1,32 @@
package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.category.create.CreateCategoryUseCase;
import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase;
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
@ApplicationScoped
public class CategoryUseCaseConfig {
@Produces
public CreateCategoryUseCase createCategoryUseCase(CategoryRepository categoryRepository,
OrganizationRepository organizationRepository,
AuthorizeOrganizationAdminUseCase authorizer) {
return new CreateCategoryUseCase(categoryRepository, organizationRepository, authorizer);
}
@Produces
public UpdateCategoryUseCase updateCategoryUseCase(CategoryRepository categoryRepository,
AuthorizeOrganizationAdminUseCase authorizer) {
return new UpdateCategoryUseCase(categoryRepository, authorizer);
}
@Produces
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository) {
return new ListCategoriesUseCase(categoryRepository);
}
}

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -18,8 +19,9 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
public class TaskUseCaseConfig { public class TaskUseCaseConfig {
@Produces @Produces
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
return new CreateTaskUseCase(taskRepository, projectRepository, userRepository); CategoryRepository categoryRepository, UserRepository userRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository);
} }
@Produces @Produces
@ -48,8 +50,9 @@ public class TaskUseCaseConfig {
} }
@Produces @Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
return new UpdateTaskUseCase(taskRepository, projectRepository, userRepository); CategoryRepository categoryRepository, UserRepository userRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository);
} }
@Produces @Produces

View file

@ -0,0 +1,64 @@
package net.beetechgroup.beetask.frameworks.persistence.entities;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class CategoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String color;
private String icon;
@ManyToOne
@JoinColumn(name = "organization_id")
private OrganizationEntity 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 String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public OrganizationEntity getOrganization() {
return organization;
}
public void setOrganization(OrganizationEntity organization) {
this.organization = organization;
}
}

View file

@ -33,6 +33,9 @@ public class TaskEntity {
@JoinColumn(name = "project_id") @JoinColumn(name = "project_id")
private ProjectEntity project; private ProjectEntity project;
@ManyToOne @ManyToOne
@JoinColumn(name = "category_id")
private CategoryEntity category;
@ManyToOne
@JoinColumn(name = "user_id") @JoinColumn(name = "user_id")
private UserEntity user; private UserEntity user;
@OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true)
@ -94,6 +97,14 @@ public class TaskEntity {
this.project = project; this.project = project;
} }
public CategoryEntity getCategory() {
return category;
}
public void setCategory(CategoryEntity category) {
this.category = category;
}
public UserEntity getUser() { public UserEntity getUser() {
return user; return user;
} }

View file

@ -0,0 +1,31 @@
package net.beetechgroup.beetask.frameworks.persistence.mapper;
import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.frameworks.persistence.entities.CategoryEntity;
public class CategoryEntityMapper {
public static CategoryEntity toEntity(Category category) {
if (Objects.isNull(category)) return null;
CategoryEntity entity = new CategoryEntity();
entity.setId(category.getId());
entity.setName(category.getName());
entity.setColor(category.getColor());
entity.setIcon(category.getIcon());
entity.setOrganization(OrganizationEntityMapper.toEntity(category.getOrganization()));
return entity;
}
public static Category toDomain(CategoryEntity entity) {
if (Objects.isNull(entity)) return null;
Category category = new Category();
category.setId(entity.getId());
category.setName(entity.getName());
category.setColor(entity.getColor());
category.setIcon(entity.getIcon());
category.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
return category;
}
}

View file

@ -17,6 +17,7 @@ public class TaskEntityMapper {
entity.setStatus(task.getStatus()); entity.setStatus(task.getStatus());
entity.setFinishedAt(task.getFinishedAt()); entity.setFinishedAt(task.getFinishedAt());
entity.setProject(ProjectEntityMapper.toEntity(task.getProject())); entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
entity.setUser(UserEntityMapper.toEntity(task.getUser())); entity.setUser(UserEntityMapper.toEntity(task.getUser()));
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> { entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h); TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
@ -36,6 +37,7 @@ public class TaskEntityMapper {
task.setStatus(entity.getStatus()); task.setStatus(entity.getStatus());
task.setFinishedAt(entity.getFinishedAt()); task.setFinishedAt(entity.getFinishedAt());
task.setProject(ProjectEntityMapper.toDomain(entity.getProject())); task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
task.setUser(UserEntityMapper.toDomain(entity.getUser())); task.setUser(UserEntityMapper.toDomain(entity.getUser()));
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList())); task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));

View file

@ -0,0 +1,60 @@
package net.beetechgroup.beetask.frameworks.persistence.repository;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.frameworks.persistence.entities.CategoryEntity;
import net.beetechgroup.beetask.frameworks.persistence.mapper.CategoryEntityMapper;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import org.jboss.logging.Logger;
@ApplicationScoped
public class CategoryRepositoryImpl implements CategoryRepository, PanacheRepository<CategoryEntity> {
private static final Logger LOGGER = Logger.getLogger(CategoryRepositoryImpl.class);
@Override
@Transactional
public Category saveCategory(Category category) {
CategoryEntity entity = CategoryEntityMapper.toEntity(category);
if (Objects.isNull(entity.getId())) {
persist(entity);
} else {
entity = getEntityManager().merge(entity);
}
Category saved = CategoryEntityMapper.toDomain(entity);
LOGGER.infof("Persisted category %d for organization %d", saved.getId(), saved.getOrganization().getId());
return saved;
}
@Override
public Optional<Category> findCategoryById(Long id) {
Optional<Category> category = findByIdOptional(id).map(CategoryEntityMapper::toDomain);
if (category.isEmpty()) {
LOGGER.warnf("Category %d was not found", id);
}
return category;
}
@Override
public List<Category> findByOrganizationId(Long organizationId) {
List<Category> categories = find("organization.id = ?1 order by name", organizationId).stream()
.map(CategoryEntityMapper::toDomain)
.toList();
LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId);
return categories;
}
@Override
public boolean existsByNameInOrganization(Long organizationId, String name, Long excludeId) {
if (Objects.isNull(excludeId)) {
return count("organization.id = ?1 and lower(name) = lower(?2)", organizationId, name) > 0;
}
return count("organization.id = ?1 and lower(name) = lower(?2) and id <> ?3",
organizationId, name, excludeId) > 0;
}
}

View file

@ -52,7 +52,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
} }
@Override @Override
public List<Task> findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status) { public List<Task> findTasksByUserFiltered(String email, String text, Long projectId,
List<TaskStatus> statuses, List<Long> categoryIds) {
StringBuilder query = new StringBuilder("user.email = ?1"); StringBuilder query = new StringBuilder("user.email = ?1");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
params.add(email); params.add(email);
@ -70,13 +71,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
params.add(projectId); params.add(projectId);
idx++; idx++;
} }
if (Objects.nonNull(status)) { if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
query.append(" and status = ?").append(idx); query.append(" and status in ?").append(idx);
params.add(status); params.add(statuses);
idx++;
}
if (Objects.nonNull(categoryIds) && !categoryIds.isEmpty()) {
query.append(" and category.id in ?").append(idx);
params.add(categoryIds);
idx++;
} }
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList(); List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList();
LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectId=%s, status=%s)", tasks.size(), email, text, projectId, status); LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectId=%s, statuses=%s, categoryIds=%s)",
tasks.size(), email, text, projectId, statuses, categoryIds);
return tasks; return tasks;
} }

View file

@ -0,0 +1,77 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
import java.util.List;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
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.core.MediaType;
import net.beetechgroup.beetask.usecase.category.create.CreateCategoryUseCase;
import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase;
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase;
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}/categories")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
@Tag(name = "Categories", description = "Category management operations")
public class CategoryController {
private static final Logger LOGGER = Logger.getLogger(CategoryController.class);
@Inject
CreateCategoryUseCase createCategoryUseCase;
@Inject
UpdateCategoryUseCase updateCategoryUseCase;
@Inject
ListCategoriesUseCase listCategoriesUseCase;
@Inject
SecurityIdentity securityIdentity;
@GET
@Operation(summary = "List categories", description = "Lists all categories for the given organization")
@APIResponse(responseCode = "200", description = "Categories listed successfully")
public List<CategoryResponse> listCategories(@PathParam("orgId") Long orgId) {
List<CategoryResponse> categories = listCategoriesUseCase.execute(orgId).stream()
.map(CategoryControllerMapper::toResponse)
.toList();
LOGGER.infof("Listed %d categories for organization %d", categories.size(), orgId);
return categories;
}
@POST
@Operation(summary = "Create a category", description = "Creates a new category in the organization")
@APIResponse(responseCode = "201", description = "Category created successfully")
public CategoryResponse createCategory(@PathParam("orgId") Long orgId, CategoryRequest request) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Category creation requested by %s for organization %d with name '%s'", email, orgId, request.name());
return CategoryControllerMapper.toResponse(
createCategoryUseCase.execute(CategoryControllerMapper.toCreateInput(email, orgId, request)));
}
@PUT
@Path("/{id}")
@Operation(summary = "Update a category", description = "Updates an existing category")
@APIResponse(responseCode = "200", description = "Category updated successfully")
public CategoryResponse updateCategory(@PathParam("orgId") Long orgId,
@PathParam("id") Long id,
CategoryRequest request) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Category update requested by %s for category %d in organization %d", email, id, orgId);
return CategoryControllerMapper.toResponse(
updateCategoryUseCase.execute(CategoryControllerMapper.toUpdateInput(email, orgId, id, request)));
}
}

View file

@ -0,0 +1,21 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
import net.beetechgroup.beetask.usecase.category.create.CreateCategoryInput;
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryInput;
public class CategoryControllerMapper {
public static CreateCategoryInput toCreateInput(String userEmail, Long organizationId, CategoryRequest request) {
return new CreateCategoryInput(userEmail, organizationId, request.name(), request.color(), request.icon());
}
public static UpdateCategoryInput toUpdateInput(String userEmail, Long organizationId, Long categoryId, CategoryRequest request) {
return new UpdateCategoryInput(userEmail, organizationId, categoryId,
request.name(), request.color(), request.icon());
}
public static CategoryResponse toResponse(CategoryOutput output) {
return new CategoryResponse(output.id(), output.name(), output.color(), output.icon());
}
}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
public record CategoryRequest(String name, String color, String icon) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
public record CategoryResponse(Long id, String name, String color, String icon) {}

View file

@ -18,6 +18,10 @@ public class MemberDetailControllerMapper {
output.projectStats().stream() output.projectStats().stream()
.map(p -> new MemberDetailResponse.ProjectStatsResponse( .map(p -> new MemberDetailResponse.ProjectStatsResponse(
p.projectId(), p.projectName(), p.totalMinutes())) p.projectId(), p.projectName(), p.totalMinutes()))
.toList(),
output.categoryStats().stream()
.map(c -> new MemberDetailResponse.CategoryStatsResponse(
c.categoryId(), c.categoryName(), c.color(), c.icon(), c.totalMinutes()))
.toList() .toList()
); );
} }

View file

@ -9,8 +9,10 @@ public record MemberDetailResponse(
String userPhoto, String userPhoto,
String groupedBy, String groupedBy,
List<PeriodStatsResponse> periodStats, List<PeriodStatsResponse> periodStats,
List<ProjectStatsResponse> projectStats List<ProjectStatsResponse> projectStats,
List<CategoryStatsResponse> categoryStats
) { ) {
public record PeriodStatsResponse(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {} public record PeriodStatsResponse(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {}
public record ProjectStatsResponse(Long projectId, String projectName, long totalMinutes) {} public record ProjectStatsResponse(Long projectId, String projectName, long totalMinutes) {}
public record CategoryStatsResponse(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}
} }

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard;
public record OrgCategoryStatsResponse(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}

View file

@ -11,6 +11,9 @@ public class OrgDashboardControllerMapper {
output.projectStats().stream() output.projectStats().stream()
.map(p -> new OrgProjectStatsResponse(p.projectId(), p.projectName(), p.totalMinutes())) .map(p -> new OrgProjectStatsResponse(p.projectId(), p.projectName(), p.totalMinutes()))
.toList(), .toList(),
output.categoryStats().stream()
.map(c -> new OrgCategoryStatsResponse(c.categoryId(), c.categoryName(), c.color(), c.icon(), c.totalMinutes()))
.toList(),
output.topTasks().stream() output.topTasks().stream()
.map(t -> new OrgTopTaskResponse(t.taskId(), t.title(), t.projectName(), t.totalMinutes())) .map(t -> new OrgTopTaskResponse(t.taskId(), t.title(), t.projectName(), t.totalMinutes()))
.toList(), .toList(),

View file

@ -4,6 +4,7 @@ import java.util.List;
public record OrgDashboardResponse( public record OrgDashboardResponse(
List<OrgProjectStatsResponse> projectStats, List<OrgProjectStatsResponse> projectStats,
List<OrgCategoryStatsResponse> categoryStats,
List<OrgTopTaskResponse> topTasks, List<OrgTopTaskResponse> topTasks,
List<OrgMemberStatsResponse> memberStats List<OrgMemberStatsResponse> memberStats
) {} ) {}

View file

@ -9,5 +9,6 @@ public record CreateTaskRequest(
String description, String description,
TaskStatus status, TaskStatus status,
Long projectId, Long projectId,
Long categoryId,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history) {
} }

View file

@ -11,10 +11,12 @@ public record CreateTaskResponse(
String description, String description,
TaskStatus status, TaskStatus status,
ProjectResponse project, ProjectResponse project,
CategoryResponse category,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history) {
public record ProjectResponse(Long id, String name) {} public record ProjectResponse(Long id, String name) {}
public record CategoryResponse(Long id, String name, String color, String icon) {}
} }
record TaskHistoryItemResponse( record TaskHistoryItemResponse(

View file

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

View file

@ -12,7 +12,7 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper { public class TaskControllerMapper {
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) { public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
return new ListMyTasksInput(email, request.text, request.projectId, request.status); return new ListMyTasksInput(email, request.text, request.projectId, request.statuses, request.categoryIds);
} }
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) { public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
@ -21,6 +21,7 @@ public class TaskControllerMapper {
request.description(), request.description(),
request.status(), request.status(),
request.projectId(), request.projectId(),
request.categoryId(),
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
@ -34,9 +35,19 @@ public class TaskControllerMapper {
output.title(), output.title(),
output.description(), output.description(),
output.status(), output.status(),
Objects.nonNull(output.project()) ? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name()) : null, Objects.nonNull(output.project())
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
: null,
Objects.nonNull(output.category())
? new CreateTaskResponse.CategoryResponse(
output.category().id(),
output.category().name(),
output.category().color(),
output.category().icon())
: null,
output.finishedAt(), output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList() output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
); );
} }
@ -55,6 +66,7 @@ public class TaskControllerMapper {
request.description(), request.description(),
request.status(), request.status(),
request.projectId(), request.projectId(),
request.categoryId(),
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))

View file

@ -0,0 +1,14 @@
package net.beetechgroup.beetask.usecase.category;
import net.beetechgroup.beetask.entities.Category;
public class CategoryMapper {
public static CategoryOutput toCategoryOutput(Category category) {
return new CategoryOutput(
category.getId(),
category.getName(),
category.getColor(),
category.getIcon());
}
}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.category;
public record CategoryOutput(Long id, String name, String color, String icon) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.category.create;
public record CreateCategoryInput(String userEmail, Long organizationId, String name, String color, String icon) {}

View file

@ -0,0 +1,56 @@
package net.beetechgroup.beetask.usecase.category.create;
import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.organization.Organization;
import net.beetechgroup.beetask.usecase.category.CategoryMapper;
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import org.jboss.logging.Logger;
public class CreateCategoryUseCase {
private static final Logger LOGGER = Logger.getLogger(CreateCategoryUseCase.class);
private final CategoryRepository categoryRepository;
private final OrganizationRepository organizationRepository;
private final AuthorizeOrganizationAdminUseCase authorizer;
public CreateCategoryUseCase(CategoryRepository categoryRepository,
OrganizationRepository organizationRepository,
AuthorizeOrganizationAdminUseCase authorizer) {
this.categoryRepository = categoryRepository;
this.organizationRepository = organizationRepository;
this.authorizer = authorizer;
}
public CategoryOutput execute(CreateCategoryInput input) {
authorizer.execute(input.userEmail(), input.organizationId());
String name = Objects.nonNull(input.name()) ? input.name().trim() : "";
if (name.isBlank()) {
throw new IllegalArgumentException("Nome da categoria é obrigatório");
}
if (categoryRepository.existsByNameInOrganization(input.organizationId(), name, null)) {
LOGGER.warnf("Category creation failed because name '%s' already exists in organization %d", name, input.organizationId());
throw new IllegalArgumentException("Já existe uma categoria com este nome");
}
Organization organization = organizationRepository.findOrganizationById(input.organizationId())
.orElseThrow(() -> {
LOGGER.warnf("Category creation failed because organization %d was not found", input.organizationId());
return new IllegalArgumentException("Organization not found");
});
Category category = new Category();
category.setName(name);
category.setColor(input.color());
category.setIcon(input.icon());
category.setOrganization(organization);
Category saved = categoryRepository.saveCategory(category);
LOGGER.infof("Category %d created in organization %d by user %s", saved.getId(), input.organizationId(), input.userEmail());
return CategoryMapper.toCategoryOutput(saved);
}
}

View file

@ -0,0 +1,25 @@
package net.beetechgroup.beetask.usecase.category.list;
import java.util.List;
import net.beetechgroup.beetask.usecase.category.CategoryMapper;
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import org.jboss.logging.Logger;
public class ListCategoriesUseCase {
private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class);
private final CategoryRepository categoryRepository;
public ListCategoriesUseCase(CategoryRepository categoryRepository) {
this.categoryRepository = categoryRepository;
}
public List<CategoryOutput> execute(Long organizationId) {
List<CategoryOutput> categories = categoryRepository.findByOrganizationId(organizationId).stream()
.map(CategoryMapper::toCategoryOutput)
.toList();
LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId);
return categories;
}
}

View file

@ -0,0 +1,4 @@
package net.beetechgroup.beetask.usecase.category.update;
public record UpdateCategoryInput(String userEmail, Long organizationId, Long categoryId,
String name, String color, String icon) {}

View file

@ -0,0 +1,54 @@
package net.beetechgroup.beetask.usecase.category.update;
import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.usecase.category.CategoryMapper;
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import org.jboss.logging.Logger;
public class UpdateCategoryUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateCategoryUseCase.class);
private final CategoryRepository categoryRepository;
private final AuthorizeOrganizationAdminUseCase authorizer;
public UpdateCategoryUseCase(CategoryRepository categoryRepository,
AuthorizeOrganizationAdminUseCase authorizer) {
this.categoryRepository = categoryRepository;
this.authorizer = authorizer;
}
public CategoryOutput execute(UpdateCategoryInput input) {
authorizer.execute(input.userEmail(), input.organizationId());
Category category = categoryRepository.findCategoryById(input.categoryId())
.orElseThrow(() -> {
LOGGER.warnf("Category update failed because category %d was not found", input.categoryId());
return new IllegalArgumentException("Categoria não encontrada");
});
if (Objects.isNull(category.getOrganization()) || !category.getOrganization().getId().equals(input.organizationId())) {
LOGGER.warnf("Category update failed because category %d does not belong to organization %d",
input.categoryId(), input.organizationId());
throw new IllegalArgumentException("Categoria não pertence à organização");
}
String name = Objects.nonNull(input.name()) ? input.name().trim() : "";
if (name.isBlank()) {
throw new IllegalArgumentException("Nome da categoria é obrigatório");
}
if (categoryRepository.existsByNameInOrganization(input.organizationId(), name, input.categoryId())) {
throw new IllegalArgumentException("Já existe uma categoria com este nome");
}
category.setName(name);
category.setColor(input.color());
category.setIcon(input.icon());
Category saved = categoryRepository.saveCategory(category);
LOGGER.infof("Category %d updated in organization %d by user %s", saved.getId(), input.organizationId(), input.userEmail());
return CategoryMapper.toCategoryOutput(saved);
}
}

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.dashboard;
public record DashboardCategoryStats(
Long categoryId,
String categoryName,
String color,
String icon,
Long totalMinutes
) {}

View file

@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
public record DashboardOutput( public record DashboardOutput(
Long totalMinutesWorked, Long totalMinutesWorked,
List<DashboardProjectStats> projectStats, List<DashboardProjectStats> projectStats,
List<DashboardCategoryStats> categoryStats,
List<CreateTaskOutput> yesterdayTasks, List<CreateTaskOutput> yesterdayTasks,
List<CreateTaskOutput> finishedTasksInPeriod List<CreateTaskOutput> finishedTasksInPeriod
) {} ) {}

View file

@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
@ -43,6 +44,7 @@ public class DashboardUseCase {
long totalMinutes = 0; long totalMinutes = 0;
Map<Long, DashboardProjectStats> projectStatsMap = new HashMap<>(); Map<Long, DashboardProjectStats> projectStatsMap = new HashMap<>();
Map<Long, DashboardCategoryStats> categoryStatsMap = new HashMap<>();
for (Task task : workedTasks) { for (Task task : workedTasks) {
long taskMinutesInPeriod = task.getHistory().stream() long taskMinutesInPeriod = task.getHistory().stream()
@ -63,11 +65,21 @@ public class DashboardUseCase {
stats.totalMinutes() + taskMinutesInPeriod stats.totalMinutes() + taskMinutesInPeriod
)); ));
} }
if (Objects.nonNull(task.getCategory())) {
Category cat = task.getCategory();
DashboardCategoryStats existing = categoryStatsMap.getOrDefault(cat.getId(),
new DashboardCategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
categoryStatsMap.put(cat.getId(), new DashboardCategoryStats(
cat.getId(), existing.categoryName(), existing.color(), existing.icon(),
existing.totalMinutes() + taskMinutesInPeriod));
}
} }
DashboardOutput output = new DashboardOutput( DashboardOutput output = new DashboardOutput(
totalMinutes, totalMinutes,
new ArrayList<>(projectStatsMap.values()), new ArrayList<>(projectStatsMap.values()),
new ArrayList<>(categoryStatsMap.values()),
yesterdayTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(), yesterdayTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(),
finishedTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList() finishedTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList()
); );

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.orgdashboard;
public record OrgCategoryStats(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}

View file

@ -4,6 +4,7 @@ import java.util.List;
public record OrgDashboardOutput( public record OrgDashboardOutput(
List<OrgProjectStats> projectStats, List<OrgProjectStats> projectStats,
List<OrgCategoryStats> categoryStats,
List<OrgTopTask> topTasks, List<OrgTopTask> topTasks,
List<OrgMemberStats> memberStats List<OrgMemberStats> memberStats
) {} ) {}

View file

@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.organization.UserOrganization; import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
@ -54,6 +55,7 @@ public class OrgDashboardUseCase {
); );
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>(); Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
Map<Long, OrgCategoryStats> categoryStatsMap = new HashMap<>();
Map<String, Long> memberMinutesMap = new HashMap<>(); Map<String, Long> memberMinutesMap = new HashMap<>();
for (Task task : workedTasks) { for (Task task : workedTasks) {
@ -66,6 +68,15 @@ public class OrgDashboardUseCase {
projectStatsMap.put(projectId, new OrgProjectStats( projectStatsMap.put(projectId, new OrgProjectStats(
projectId, existing.projectName(), existing.totalMinutes() + minutes)); projectId, existing.projectName(), existing.totalMinutes() + minutes));
if (Objects.nonNull(task.getCategory())) {
Category cat = task.getCategory();
OrgCategoryStats existingCat = categoryStatsMap.getOrDefault(cat.getId(),
new OrgCategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
categoryStatsMap.put(cat.getId(), new OrgCategoryStats(
cat.getId(), existingCat.categoryName(), existingCat.color(), existingCat.icon(),
existingCat.totalMinutes() + minutes));
}
if (Objects.nonNull(task.getUser())) { if (Objects.nonNull(task.getUser())) {
memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum); memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum);
} }
@ -104,6 +115,7 @@ public class OrgDashboardUseCase {
OrgDashboardOutput output = new OrgDashboardOutput( OrgDashboardOutput output = new OrgDashboardOutput(
new ArrayList<>(projectStatsMap.values()), new ArrayList<>(projectStatsMap.values()),
new ArrayList<>(categoryStatsMap.values()),
topTasks, topTasks,
memberStats memberStats
); );

View file

@ -9,7 +9,8 @@ public class MemberDetailMapper {
public static MemberDetailOutput toOutput(UserOrganization member, public static MemberDetailOutput toOutput(UserOrganization member,
String groupedBy, String groupedBy,
List<MemberDetailOutput.PeriodStats> periodStats, List<MemberDetailOutput.PeriodStats> periodStats,
List<MemberDetailOutput.ProjectStats> projectStats) { List<MemberDetailOutput.ProjectStats> projectStats,
List<MemberDetailOutput.CategoryStats> categoryStats) {
return new MemberDetailOutput( return new MemberDetailOutput(
member.getUser().getId(), member.getUser().getId(),
member.getUser().getName(), member.getUser().getName(),
@ -17,7 +18,8 @@ public class MemberDetailMapper {
member.getUser().getPhoto(), member.getUser().getPhoto(),
groupedBy, groupedBy,
periodStats, periodStats,
projectStats projectStats,
categoryStats
); );
} }
} }

View file

@ -9,8 +9,10 @@ public record MemberDetailOutput(
String userPhoto, String userPhoto,
String groupedBy, String groupedBy,
List<PeriodStats> periodStats, List<PeriodStats> periodStats,
List<ProjectStats> projectStats List<ProjectStats> projectStats,
List<CategoryStats> categoryStats
) { ) {
public record PeriodStats(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {} public record PeriodStats(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {}
public record ProjectStats(Long projectId, String projectName, long totalMinutes) {} public record ProjectStats(Long projectId, String projectName, long totalMinutes) {}
public record CategoryStats(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}
} }

View file

@ -1,5 +1,6 @@
package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail; package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.organization.UserOrganization; import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
@ -57,6 +58,7 @@ public class MemberDetailUseCase {
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end); List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>(); Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>();
Map<LocalDate, Long> minutesPerDay = new HashMap<>(); Map<LocalDate, Long> minutesPerDay = new HashMap<>();
Map<YearMonth, Long> minutesPerMonth = new HashMap<>(); Map<YearMonth, Long> minutesPerMonth = new HashMap<>();
@ -81,6 +83,15 @@ public class MemberDetailUseCase {
new MemberDetailOutput.ProjectStats(projectId, projectName, 0L)); new MemberDetailOutput.ProjectStats(projectId, projectName, 0L));
projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats( projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats(
projectId, existing.projectName(), existing.totalMinutes() + minutes)); projectId, existing.projectName(), existing.totalMinutes() + minutes));
if (Objects.nonNull(task.getCategory())) {
Category cat = task.getCategory();
MemberDetailOutput.CategoryStats existingCat = categoryStatsMap.getOrDefault(cat.getId(),
new MemberDetailOutput.CategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
categoryStatsMap.put(cat.getId(), new MemberDetailOutput.CategoryStats(
cat.getId(), existingCat.categoryName(), existingCat.color(), existingCat.icon(),
existingCat.totalMinutes() + minutes));
}
} }
} }
@ -123,7 +134,8 @@ public class MemberDetailUseCase {
} }
MemberDetailOutput output = MemberDetailMapper.toOutput(member, groupByDay ? "DAY" : "MONTH", periodStats, MemberDetailOutput output = MemberDetailMapper.toOutput(member, groupByDay ? "DAY" : "MONTH", periodStats,
new ArrayList<>(projectStatsMap.values())); new ArrayList<>(projectStatsMap.values()),
new ArrayList<>(categoryStatsMap.values()));
LOGGER.infof("Member detail calculated for member %d in organization %d with %d worked tasks and %d finished tasks", LOGGER.infof("Member detail calculated for member %d in organization %d with %d worked tasks and %d finished tasks",
input.memberId(), input.organizationId(), workedTasks.size(), finishedTasks.size()); input.memberId(), input.organizationId(), workedTasks.size(), finishedTasks.size());
return output; return output;

View file

@ -0,0 +1,13 @@
package net.beetechgroup.beetask.usecase.repository;
import java.util.List;
import java.util.Optional;
import net.beetechgroup.beetask.entities.Category;
public interface CategoryRepository {
Category saveCategory(Category category);
Optional<Category> findCategoryById(Long id);
List<Category> findByOrganizationId(Long organizationId);
boolean existsByNameInOrganization(Long organizationId, String name, Long excludeId);
}

View file

@ -13,7 +13,8 @@ public interface TaskRepository {
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email); List<Task> findTasksByUser(String email);
List<Task> findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status); List<Task> findTasksByUserFiltered(String email, String text, Long projectId,
List<TaskStatus> statuses, List<Long> categoryIds);
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);

View file

@ -9,6 +9,7 @@ public record CreateTaskInput(
String description, String description,
TaskStatus status, TaskStatus status,
Long projectId, Long projectId,
Long categoryId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history) {
} }

View file

@ -11,7 +11,16 @@ public class CreateTaskMapper {
task.getTitle(), task.getTitle(),
task.getDescription(), task.getDescription(),
task.getStatus(), task.getStatus(),
Objects.nonNull(task.getProject()) ? new CreateTaskOutput.ProjectOutput(task.getProject().getId(), task.getProject().getName()) : null, Objects.nonNull(task.getProject())
? new CreateTaskOutput.ProjectOutput(task.getProject().getId(), task.getProject().getName())
: null,
Objects.nonNull(task.getCategory())
? new CreateTaskOutput.CategoryOutput(
task.getCategory().getId(),
task.getCategory().getName(),
task.getCategory().getColor(),
task.getCategory().getIcon())
: null,
task.getFinishedAt(), task.getFinishedAt(),
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList()); task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
} }

View file

@ -10,8 +10,10 @@ public record CreateTaskOutput(
String description, String description,
TaskStatus status, TaskStatus status,
ProjectOutput project, ProjectOutput project,
CategoryOutput category,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemOutput> history) { List<TaskHistoryItemOutput> history) {
public record ProjectOutput(Long id, String name) {} public record ProjectOutput(Long id, String name) {}
} public record CategoryOutput(Long id, String name, String color, String icon) {}
}

View file

@ -2,9 +2,11 @@ package net.beetechgroup.beetask.usecase.task.create;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -14,11 +16,14 @@ public class CreateTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(CreateTaskUseCase.class); private static final Logger LOGGER = Logger.getLogger(CreateTaskUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@ -38,6 +43,15 @@ public class CreateTaskUseCase {
task.setProject(project); task.setProject(project);
} }
if (Objects.nonNull(input.categoryId())) {
Category category = categoryRepository.findCategoryById(input.categoryId())
.orElseThrow(() -> {
LOGGER.warnf("Task creation failed because category %d was not found", input.categoryId());
return new IllegalArgumentException("Categoria não encontrada com ID: " + input.categoryId());
});
task.setCategory(category);
}
task.setUser(userRepository.findByEmail(input.userEmail()) task.setUser(userRepository.findByEmail(input.userEmail())
.orElseThrow(() -> { .orElseThrow(() -> {
LOGGER.warnf("Task creation failed because user %s was not found", input.userEmail()); LOGGER.warnf("Task creation failed because user %s was not found", input.userEmail());

View file

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

View file

@ -17,7 +17,8 @@ public class ListMyTasksUseCase {
} }
public List<CreateTaskOutput> execute(ListMyTasksInput input) { public List<CreateTaskOutput> execute(ListMyTasksInput input) {
List<CreateTaskOutput> tasks = taskRepository.findTasksByUserFiltered(input.email(), input.text(), input.projectId(), input.status()) List<CreateTaskOutput> tasks = taskRepository.findTasksByUserFiltered(
input.email(), input.text(), input.projectId(), input.statuses(), input.categoryIds())
.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); .stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email()); LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email());
return tasks; return tasks;

View file

@ -9,6 +9,7 @@ public record UpdateTaskInput(
String description, String description,
TaskStatus status, TaskStatus status,
Long projectId, Long projectId,
Long categoryId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history) {
} }

View file

@ -2,9 +2,11 @@ package net.beetechgroup.beetask.usecase.task.update;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -16,11 +18,14 @@ public class UpdateTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateTaskUseCase.class); private static final Logger LOGGER = Logger.getLogger(UpdateTaskUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@ -51,6 +56,17 @@ public class UpdateTaskUseCase {
task.setProject(null); task.setProject(null);
} }
if (Objects.nonNull(input.categoryId())) {
Category category = categoryRepository.findCategoryById(input.categoryId())
.orElseThrow(() -> {
LOGGER.warnf("Task update failed because category %d was not found", input.categoryId());
return new IllegalArgumentException("Categoria não encontrada com ID: " + input.categoryId());
});
task.setCategory(category);
} else {
task.setCategory(null);
}
if (Objects.nonNull(input.history())) { if (Objects.nonNull(input.history())) {
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> { List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
if (Objects.nonNull(hInput.id())) { if (Objects.nonNull(hInput.id())) {

View file

@ -0,0 +1,18 @@
CREATE TABLE categories (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(120) NOT NULL,
color VARCHAR(9) NOT NULL,
icon VARCHAR(64) NOT NULL,
organization_id BIGINT NOT NULL,
CONSTRAINT fk_categories_organization
FOREIGN KEY (organization_id) REFERENCES organizations (id),
CONSTRAINT uk_categories_org_name UNIQUE (organization_id, name)
);
CREATE INDEX idx_categories_organization_id ON categories (organization_id);
ALTER TABLE tasks ADD COLUMN category_id BIGINT;
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_category
FOREIGN KEY (category_id) REFERENCES categories (id);
CREATE INDEX idx_tasks_category_id ON tasks (category_id);

View file

@ -10,6 +10,7 @@ import {
Clock, Clock,
ChevronDown, ChevronDown,
FolderKanban, FolderKanban,
Tag,
BarChart3, BarChart3,
PieChart, PieChart,
Search, Search,
@ -267,6 +268,13 @@ export function AppShell() {
</NavLink> </NavLink>
)} )}
{isAdmin && (
<NavLink to="/categories" className={navLinkClassName}>
<Tag size={18} />
Categorias
</NavLink>
)}
{/* Equipe Dropdown */} {/* Equipe Dropdown */}
<div className="relative"> <div className="relative">
<NavLink <NavLink

View file

@ -0,0 +1,22 @@
import type { TaskCategory } from '../types/task'
import { CategoryIcon } from './CategoryIcon'
interface CategoryBadgeProps {
category: TaskCategory | null | undefined
size?: number
showLabel?: boolean
}
export function CategoryBadge({ category, size = 14, showLabel = false }: CategoryBadgeProps) {
if (!category) return null
return (
<span
title={category.name}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[10px] font-bold"
style={{ background: `${category.color}15`, color: category.color }}
>
<CategoryIcon iconName={category.icon} color={category.color} size={size} />
{showLabel && category.name}
</span>
)
}

View file

@ -0,0 +1,16 @@
import * as LucideIcons from 'lucide-react'
import { Tag } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface CategoryIconProps {
iconName?: string | null
color?: string | null
size?: number
className?: string
}
export function CategoryIcon({ iconName, color, size = 16, className }: CategoryIconProps) {
const icons = LucideIcons as unknown as Record<string, LucideIcon>
const IconComponent = (iconName && icons[iconName]) || Tag
return <IconComponent size={size} color={color ?? undefined} className={className} />
}

View file

@ -0,0 +1,88 @@
import { useMemo, useState } from 'react'
import * as LucideIcons from 'lucide-react'
import { Search } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../lib/utils'
interface IconPickerProps {
value: string
onChange: (iconName: string) => void
color?: string
}
const EXCLUDED = new Set(['createLucideIcon', 'icons', 'default', 'Icon', 'LucideIcon'])
function getAllIconNames(): string[] {
const icons = LucideIcons as unknown as Record<string, unknown>
return Object.keys(icons)
.filter((name) => /^[A-Z]/.test(name))
.filter((name) => !EXCLUDED.has(name))
.filter((name) => !name.endsWith('Icon'))
.sort()
}
const ALL_ICON_NAMES = getAllIconNames()
const MAX_VISIBLE = 144
export function IconPicker({ value, onChange, color }: IconPickerProps) {
const [search, setSearch] = useState('')
const icons = LucideIcons as unknown as Record<string, LucideIcon>
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return ALL_ICON_NAMES.slice(0, MAX_VISIBLE)
return ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q)).slice(0, MAX_VISIBLE)
}, [search])
return (
<div className="space-y-3">
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
size={16}
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar ícone..."
className="h-10 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-3 text-sm outline-none focus:border-brand focus:ring-2 focus:ring-brand/10"
/>
</div>
<div className="grid grid-cols-8 gap-1.5 max-h-60 overflow-y-auto rounded-xl border border-border-soft bg-app-bg p-2">
{filtered.map((name) => {
const Icon = icons[name]
if (!Icon) return null
const selected = value === name
return (
<button
key={name}
type="button"
onClick={() => onChange(name)}
title={name}
className={cn(
'flex items-center justify-center rounded-lg p-2 transition-all',
selected
? 'bg-brand text-white shadow-md shadow-brand/30'
: 'text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
style={selected && color ? { background: color, color: '#fff' } : undefined}
>
<Icon size={18} />
</button>
)
})}
{filtered.length === 0 && (
<p className="col-span-8 py-6 text-center text-xs font-bold text-text-muted">
Nenhum ícone encontrado.
</p>
)}
</div>
{value && (
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted">
Selecionado: <span className="text-text-main">{value}</span>
</p>
)}
</div>
)
}

View file

@ -7,7 +7,9 @@ import {
type MemberDetailData, type MemberDetailData,
type PeriodStats, type PeriodStats,
type MemberProjectStats, type MemberProjectStats,
type MemberCategoryStats,
} from "../services/orgDashboardService"; } from "../services/orgDashboardService";
import { CategoryIcon } from "./CategoryIcon";
const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]; const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"];
const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"]; const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"];
@ -155,6 +157,69 @@ function BarChart({
); );
} }
function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const total = data.reduce((s, d) => s + d.totalMinutes, 0);
if (total === 0 || data.length === 0) {
return (
<div className="flex items-center justify-center h-full opacity-40 text-xs font-bold">
Sem dados
</div>
);
}
const cx = 50, cy = 50, r = 38, innerR = 22;
let startAngle = -Math.PI / 2;
const slices = data.map((d) => {
const fraction = d.totalMinutes / total;
const angle = fraction * 2 * Math.PI;
const endAngle = startAngle + angle;
const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle);
const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`;
const pct = (fraction * 100).toFixed(0);
const result = { path, color: d.color, label: `${d.categoryName}: ${formatMinutes(d.totalMinutes)} (${pct}%)` };
startAngle = endAngle;
return result;
});
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return (
<div className="relative w-full h-full">
<svg
ref={svgRef}
viewBox="0 0 100 100"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{slices.map((s, i) => (
<path
key={i}
d={s.path}
fill={s.color}
stroke="var(--color-surface,#fff)"
strokeWidth="1.5"
className="cursor-pointer transition-opacity hover:opacity-90"
onMouseMove={(e) => handleMouseMove(e, s.label)}
/>
))}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
);
}
function MiniPieChart({ data }: { data: MemberProjectStats[] }) { function MiniPieChart({ data }: { data: MemberProjectStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null); const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
@ -374,6 +439,43 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
</div> </div>
</div> </div>
{/* Pie chart — hours by category */}
{data.categoryStats.length > 0 && (
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-3">
<span className="text-xs font-black text-text-main">Horas por Categoria</span>
</div>
<div className="flex items-center gap-6">
<div className="w-20 h-20 shrink-0">
<CategoryMiniPieChart data={data.categoryStats} />
</div>
<div className="flex-1 space-y-1.5">
{data.categoryStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((c) => {
const totalCat = data.categoryStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = totalCat > 0 ? ((c.totalMinutes / totalCat) * 100).toFixed(0) : "0";
return (
<div key={c.categoryId} className="flex items-center gap-2">
<div
className="flex h-5 w-5 items-center justify-center rounded-md shrink-0"
style={{ background: `${c.color}1a`, color: c.color }}
>
<CategoryIcon iconName={c.icon} color={c.color} size={12} />
</div>
<span className="text-xs font-bold text-text-main truncate flex-1">{c.categoryName}</span>
<span className="text-xs font-black text-text-muted shrink-0">
{formatMinutes(c.totalMinutes)} ({pct}%)
</span>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Pie chart — hours by project */} {/* Pie chart — hours by project */}
{data.projectStats.length > 0 && ( {data.projectStats.length > 0 && (
<div className="bg-app-bg border border-border-soft rounded-xl p-4"> <div className="bg-app-bg border border-border-soft rounded-xl p-4">

View file

@ -0,0 +1,70 @@
import type { ReactNode } from 'react'
import { cn } from '../lib/utils'
export interface MultiSelectOption<T extends string | number> {
value: T
label: string
icon?: ReactNode
color?: string
}
interface MultiSelectChipsProps<T extends string | number> {
options: MultiSelectOption<T>[]
selected: T[]
onChange: (selected: T[]) => void
emptyLabel?: string
}
export function MultiSelectChips<T extends string | number>({
options,
selected,
onChange,
emptyLabel = 'Todos',
}: MultiSelectChipsProps<T>) {
const toggle = (value: T) => {
if (selected.includes(value)) {
onChange(selected.filter((v) => v !== value))
} else {
onChange([...selected, value])
}
}
const allSelected = selected.length === 0
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<button
type="button"
onClick={() => onChange([])}
className={cn(
'whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-bold transition-all',
allSelected
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
>
{emptyLabel}
</button>
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<button
key={String(option.value)}
type="button"
onClick={() => toggle(option.value)}
className={cn(
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-bold transition-all',
isSelected
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
style={isSelected && option.color ? { background: option.color, color: '#fff' } : undefined}
>
{option.icon}
{option.label}
</button>
)
})}
</div>
)
}

View file

@ -0,0 +1,121 @@
import { useRef, useState } from 'react'
import type { ReactNode } from 'react'
export interface PieSlice {
key: string | number
label: string
value: number
color: string
legendIcon?: ReactNode
}
interface PieChartProps {
slices: PieSlice[]
centerLabel?: string
centerValue?: string | number
size?: number
formatTooltip?: (slice: PieSlice, percent: number) => string
}
interface TooltipState {
x: number
y: number
content: string
}
export function PieChart({ slices, centerLabel, centerValue, size = 200, formatTooltip }: PieChartProps) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const total = slices.reduce((sum, s) => sum + s.value, 0)
if (total === 0 || slices.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full opacity-40 font-bold text-sm">
Sem dados no período
</div>
)
}
const cx = 100
const cy = 100
const r = 70
const innerR = 42
let startAngle = -Math.PI / 2
const paths = slices.map((slice) => {
const fraction = slice.value / total
const angle = fraction * 2 * Math.PI
const endAngle = startAngle + angle
const x1 = cx + r * Math.cos(startAngle)
const y1 = cy + r * Math.sin(startAngle)
const x2 = cx + r * Math.cos(endAngle)
const y2 = cy + r * Math.sin(endAngle)
const ix1 = cx + innerR * Math.cos(endAngle)
const iy1 = cy + innerR * Math.sin(endAngle)
const ix2 = cx + innerR * Math.cos(startAngle)
const iy2 = cy + innerR * Math.sin(startAngle)
const largeArc = angle > Math.PI ? 1 : 0
const path = [
`M ${x1} ${y1}`,
`A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`,
`L ${ix1} ${iy1}`,
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2}`,
'Z',
].join(' ')
const pct = fraction * 100
const tip = formatTooltip
? formatTooltip(slice, pct)
: `${slice.label}: ${slice.value} (${pct.toFixed(1)}%)`
startAngle = endAngle
return { path, color: slice.color, tip }
})
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content })
}
return (
<div className="relative" style={{ width: size, height: size }}>
<svg
ref={svgRef}
viewBox="0 0 200 200"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{paths.map((slice, i) => (
<path
key={i}
d={slice.path}
fill={slice.color}
stroke="var(--color-surface, #fff)"
strokeWidth="2"
className="cursor-pointer transition-opacity hover:opacity-80"
onMouseMove={(e) => handleMouseMove(e, slice.tip)}
/>
))}
{centerLabel && (
<text x={cx} y={cy - 8} textAnchor="middle" fontSize="11" fill="currentColor" opacity="0.5">
{centerLabel}
</text>
)}
{centerValue !== undefined && (
<text x={cx} y={cy + 10} textAnchor="middle" fontSize="20" fontWeight="900" fill="currentColor">
{centerValue}
</text>
)}
</svg>
{tooltip && (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 36, transform: 'translateX(-50%)' }}
>
{tooltip.content}
</div>
)}
</div>
)
}

View file

@ -1,18 +1,22 @@
import { Filter, RefreshCw, Search } from 'lucide-react' import { Filter, RefreshCw, Search } from 'lucide-react'
import { cn } from '../lib/utils'
import type { TaskStatus } from '../types/task' import type { TaskStatus } from '../types/task'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task' import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import type { Category } from '../types/category'
import { MultiSelectChips } from './MultiSelectChips'
import { CategoryIcon } from './CategoryIcon'
export interface TaskFilters { export interface TaskFilters {
searchQuery: string searchQuery: string
statusFilter: TaskStatus | 'ALL' statuses: TaskStatus[]
categoryIds: number[]
projectId: number | 'ALL' projectId: number | 'ALL'
} }
export const DEFAULT_TASK_FILTERS: TaskFilters = { export const DEFAULT_TASK_FILTERS: TaskFilters = {
searchQuery: '', searchQuery: '',
statusFilter: 'ALL', statuses: [],
categoryIds: [],
projectId: 'ALL', projectId: 'ALL',
} }
@ -20,6 +24,7 @@ interface TaskFilterBarProps {
filters: TaskFilters filters: TaskFilters
onFiltersChange: (filters: TaskFilters) => void onFiltersChange: (filters: TaskFilters) => void
projects: Project[] projects: Project[]
categories: Category[]
isLoading?: boolean isLoading?: boolean
onRefresh?: () => void onRefresh?: () => void
} }
@ -28,6 +33,7 @@ export function TaskFilterBar({
filters, filters,
onFiltersChange, onFiltersChange,
projects, projects,
categories,
isLoading, isLoading,
onRefresh, onRefresh,
}: TaskFilterBarProps) { }: TaskFilterBarProps) {
@ -35,66 +41,77 @@ export function TaskFilterBar({
onFiltersChange({ ...filters, ...partial }) onFiltersChange({ ...filters, ...partial })
return ( return (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm lg:flex-row lg:items-center"> <div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm">
<div className="relative flex-1"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Search <div className="relative flex-1">
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" <Search
size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
/> size={18}
<input />
type="text" <input
placeholder="Buscar por título ou descrição..." type="text"
value={filters.searchQuery} placeholder="Buscar por título ou descrição..."
onChange={(e) => update({ searchQuery: e.target.value })} value={filters.searchQuery}
className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10" onChange={(e) => update({ searchQuery: e.target.value })}
/> className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
</div> />
</div>
{projects.length > 0 && ( {projects.length > 0 && (
<select <select
value={filters.projectId} value={filters.projectId}
onChange={(e) => onChange={(e) =>
update({ update({
projectId: e.target.value === 'ALL' ? 'ALL' : Number(e.target.value), projectId: e.target.value === 'ALL' ? 'ALL' : Number(e.target.value),
}) })
} }
className="h-11 rounded-xl border border-border-soft bg-app-bg px-3 text-sm text-text-main outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10" className="h-11 rounded-xl border border-border-soft bg-app-bg px-3 text-sm text-text-main outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
>
<option value="ALL">Todos os projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
<Filter size={18} className="mr-2 shrink-0 text-text-muted" />
{(['ALL', ...TASK_STATUS_OPTIONS] as const).map((status) => (
<button
key={status}
onClick={() => update({ statusFilter: status })}
className={cn(
'whitespace-nowrap rounded-lg px-4 py-2 text-xs font-bold transition-all',
filters.statusFilter === status
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
> >
{status === 'ALL' ? 'Todos' : TASK_STATUS_LABELS[status]} <option value="ALL">Todos os projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
{onRefresh && (
<button
onClick={onRefresh}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
title="Recarregar"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button> </button>
))} )}
</div> </div>
{onRefresh && ( <div className="flex items-center gap-2">
<button <Filter size={16} className="shrink-0 text-text-muted" />
onClick={onRefresh} <span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Status</span>
className="flex h-11 w-11 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand" <MultiSelectChips
title="Recarregar" options={TASK_STATUS_OPTIONS.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))}
> selected={filters.statuses}
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} /> onChange={(statuses) => update({ statuses })}
</button> />
</div>
{categories.length > 0 && (
<div className="flex items-center gap-2">
<Filter size={16} className="shrink-0 text-text-muted" />
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Categorias</span>
<MultiSelectChips
options={categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
icon: <CategoryIcon iconName={c.icon} size={14} />,
}))}
selected={filters.categoryIds}
onChange={(categoryIds) => update({ categoryIds })}
/>
</div>
)} )}
</div> </div>
) )

View file

@ -0,0 +1,294 @@
import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowRight, Loader2, Pencil, Plus, Search, Tag, X } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { categoryService } from '../services/categoryService'
import type { Category, CategoryPayload } from '../types/category'
import { CategoryIcon } from '../components/CategoryIcon'
import { IconPicker } from '../components/IconPicker'
const DEFAULT_COLOR = '#7C3AED'
const DEFAULT_ICON = 'Tag'
export const CategoriesPage: React.FC = () => {
const { activeOrg } = useAuth()
const [categories, setCategories] = useState<Category[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [form, setForm] = useState<CategoryPayload>({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON })
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const fetchCategories = async () => {
if (!activeOrg) return
setIsLoading(true)
try {
const data = await categoryService.listByOrganization(activeOrg.id)
setCategories(data)
} catch (err) {
console.error('Falha ao carregar categorias', err)
setError('Não foi possível carregar as categorias.')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchCategories()
}, [activeOrg])
const openCreate = () => {
setEditing(null)
setForm({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON })
setError(null)
setShowForm(true)
}
const openEdit = (category: Category) => {
setEditing(category)
setForm({ name: category.name, color: category.color, icon: category.icon })
setError(null)
setShowForm(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!activeOrg) return
setIsSubmitting(true)
setError(null)
try {
if (editing) {
const updated = await categoryService.update(activeOrg.id, editing.id, form)
setCategories((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
} else {
const created = await categoryService.create(activeOrg.id, form)
setCategories((prev) => [...prev, created])
}
setShowForm(false)
} catch (err) {
console.error('Falha ao salvar categoria', err)
setError(err instanceof Error ? err.message : 'Falha ao salvar categoria.')
} finally {
setIsSubmitting(false)
}
}
const filtered = categories.filter((c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div className="space-y-10 pb-20">
<header className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2 text-brand font-black uppercase tracking-widest text-[10px] mb-2">
<Tag size={14} />
Workspace
</div>
<h1 className="text-4xl font-black tracking-tight text-text-main">
Categorias em <span className="text-brand">{activeOrg?.name ?? '...'}</span>
</h1>
<p className="mt-2 text-lg text-text-muted">
Crie categorias para classificar e medir o tempo das suas tarefas.
</p>
</div>
<div className="flex items-center gap-3">
<div className="relative hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" size={16} />
<input
type="text"
placeholder="Buscar categorias..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2.5 bg-surface border border-border-soft rounded-xl text-sm outline-none focus:border-brand focus:ring-4 focus:ring-brand/5 transition-all w-56 font-medium"
/>
</div>
<button
onClick={openCreate}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95"
>
<Plus size={18} />
Nova Categoria
</button>
</div>
</header>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-[2rem] border border-border-soft bg-surface-muted" />
))}
</div>
) : (
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<AnimatePresence mode="popLayout">
{filtered.map((category, index) => (
<motion.div
key={category.id}
layout
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: index * 0.04 }}
className="group relative flex items-center gap-4 rounded-[2rem] border border-border-soft bg-surface p-6 transition-all hover:shadow-xl hover:shadow-brand/5"
>
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl shrink-0"
style={{ background: `${category.color}1a`, color: category.color }}
>
<CategoryIcon iconName={category.icon} color={category.color} size={26} />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-black text-text-main truncate">{category.name}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mt-1">
{category.icon} · {category.color}
</p>
</div>
<button
onClick={() => openEdit(category)}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-brand hover:text-white"
title="Editar categoria"
>
<Pencil size={16} />
</button>
</motion.div>
))}
<button
onClick={openCreate}
className="flex min-h-[7rem] flex-col items-center justify-center gap-2 rounded-[2rem] border-2 border-dashed border-border-soft bg-surface/50 p-6 text-center transition-all hover:border-brand/30 hover:bg-brand/5 active:scale-[0.98] group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-app-bg text-text-muted transition-colors group-hover:bg-brand group-hover:text-white">
<Plus size={24} />
</div>
<span className="text-sm font-bold text-text-main">Nova categoria</span>
</button>
</AnimatePresence>
</section>
)}
{!isLoading && filtered.length === 0 && categories.length === 0 && (
<div className="text-center py-16 bg-surface/50 rounded-[3rem] border-2 border-dashed border-border-soft">
<div className="w-16 h-16 bg-surface-muted rounded-2xl flex items-center justify-center mx-auto mb-6 text-text-muted/40">
<Tag size={32} />
</div>
<h3 className="text-xl font-bold text-text-main mb-2">Nenhuma categoria cadastrada</h3>
<p className="text-text-muted max-w-xs mx-auto font-medium">
Crie sua primeira categoria para começar a classificar tarefas e medir tempo.
</p>
</div>
)}
<AnimatePresence>
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-2xl bg-surface border border-border-soft rounded-[2.5rem] shadow-panel p-8 relative"
>
<button
onClick={() => setShowForm(false)}
className="absolute top-6 right-6 text-text-muted hover:text-brand transition-colors"
>
<X size={22} />
</button>
<div className="mb-8 flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-2xl"
style={{ background: `${form.color}1a`, color: form.color }}
>
<CategoryIcon iconName={form.icon} color={form.color} size={24} />
</div>
<div>
<h2 className="text-2xl font-black text-text-main">
{editing ? 'Editar categoria' : 'Nova categoria'}
</h2>
<p className="text-sm text-text-muted">
Defina nome, cor e ícone para identificar suas tarefas.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<label className="block space-y-2">
<span className="text-sm font-bold text-text-main">Nome</span>
<input
autoFocus
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Ex.: Bug, Feature, Reunião"
className="block w-full h-12 px-4 bg-app-bg border border-border-soft rounded-xl text-sm font-medium outline-none focus:border-brand focus:ring-4 focus:ring-brand/10"
/>
</label>
<label className="block space-y-2">
<span className="text-sm font-bold text-text-main">Cor</span>
<div className="flex items-center gap-3">
<input
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
className="h-12 w-16 cursor-pointer rounded-xl border border-border-soft bg-app-bg"
/>
<input
type="text"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
className="flex-1 h-12 px-4 bg-app-bg border border-border-soft rounded-xl text-sm font-mono outline-none focus:border-brand"
/>
</div>
</label>
</div>
<div className="space-y-2">
<span className="text-sm font-bold text-text-main">Ícone</span>
<IconPicker
value={form.icon}
onChange={(icon) => setForm({ ...form, icon })}
color={form.color}
/>
</div>
{error && (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-2xl text-rose-500 text-xs font-bold flex items-center gap-3">
<X size={16} />
{error}
</div>
)}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={() => setShowForm(false)}
className="px-5 py-3 text-sm font-bold text-text-muted hover:text-text-main"
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting || !form.name.trim() || !form.icon}
className="inline-flex min-w-44 items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-black text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95 disabled:opacity-50"
>
{isSubmitting ? (
<Loader2 className="animate-spin" size={18} />
) : (
<>
{editing ? 'Salvar' : 'Criar categoria'}
<ArrowRight size={18} />
</>
)}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -21,9 +21,13 @@ import type { CreateTaskPayload, TaskResponse, TaskHistoryItem } from "../types/
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useEffect } from "react"; import { useEffect } from "react";
import { projectService, type Project } from "../services/projectService"; import { projectService, type Project } from "../services/projectService";
import { categoryService } from "../services/categoryService";
import type { Category } from "../types/category";
import { CategoryIcon } from "../components/CategoryIcon";
type FormState = Omit<CreateTaskPayload, 'projectId'> & { type FormState = Omit<CreateTaskPayload, 'projectId' | 'categoryId'> & {
projectId: string; projectId: string;
categoryId: string;
history: TaskHistoryItem[]; history: TaskHistoryItem[];
}; };
@ -32,6 +36,7 @@ const initialFormState: FormState = {
description: "", description: "",
status: "NOT_STARTED", status: "NOT_STARTED",
projectId: "", projectId: "",
categoryId: "",
history: [], history: [],
}; };
@ -41,6 +46,7 @@ export function CreateTaskPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null); const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const { activeOrg } = useAuth(); const { activeOrg } = useAuth();
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -56,6 +62,7 @@ export function CreateTaskPage() {
description: data.description || "", description: data.description || "",
status: data.status, status: data.status,
projectId: data.project?.id?.toString() || "", projectId: data.project?.id?.toString() || "",
categoryId: data.category?.id?.toString() || "",
history: data.history || [], history: data.history || [],
}); });
} catch (error) { } catch (error) {
@ -77,7 +84,17 @@ export function CreateTaskPage() {
console.error("Erro ao carregar projetos:", error); console.error("Erro ao carregar projetos:", error);
} }
} }
async function loadCategories() {
if (!activeOrg) return;
try {
const data = await categoryService.listByOrganization(activeOrg.id);
setCategories(data);
} catch (error) {
console.error("Erro ao carregar categorias:", error);
}
}
loadProjects(); loadProjects();
loadCategories();
}, [activeOrg]); }, [activeOrg]);
function updateField( function updateField(
@ -103,6 +120,7 @@ export function CreateTaskPage() {
description: form.description.trim(), description: form.description.trim(),
status: form.status, status: form.status,
projectId: form.projectId ? Number(form.projectId) : null, projectId: form.projectId ? Number(form.projectId) : null,
categoryId: form.categoryId ? Number(form.categoryId) : null,
history: form.history, history: form.history,
}; };
@ -233,8 +251,8 @@ export function CreateTaskPage() {
</label> </label>
</div> </div>
{/* Project & Status Row */} {/* Project, Category & Status Row */}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-3">
<label className="group block"> <label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand"> <div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Layout size={16} /> <Layout size={16} />
@ -255,6 +273,37 @@ export function CreateTaskPage() {
</select> </select>
</label> </label>
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
{form.categoryId ? (
(() => {
const c = categories.find((cat) => cat.id === Number(form.categoryId));
return c ? (
<CategoryIcon iconName={c.icon} color={c.color} size={16} />
) : (
<Layout size={16} />
);
})()
) : (
<Layout size={16} />
)}
Categoria
</div>
<select
name="categoryId"
value={form.categoryId || ""}
onChange={updateField}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
>
<option value="">Sem Categoria</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</label>
<label className="group block"> <label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand"> <div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Layout size={16} /> <Layout size={16} />

View file

@ -22,6 +22,8 @@ import {
type DashboardData, type DashboardData,
} from "../services/dashboardService"; } from "../services/dashboardService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { PieChart } from "../components/PieChart";
import { CategoryIcon } from "../components/CategoryIcon";
import { format, subDays, startOfMonth, endOfMonth } from "date-fns"; import { format, subDays, startOfMonth, endOfMonth } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
@ -227,6 +229,65 @@ export const DashboardPage: React.FC = () => {
</motion.div> </motion.div>
</div> </div>
{/* Category Pie */}
{data.categoryStats.length > 0 && (
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
<div className="flex items-center gap-2 mb-8">
<BarChart3 size={20} className="text-brand" />
<h2 className="text-xl font-black text-text-main">Tempo por Categoria</h2>
</div>
<div className="flex flex-col sm:flex-row items-center gap-8">
<PieChart
size={200}
centerLabel="categorias"
centerValue={data.categoryStats.length}
slices={data.categoryStats.map((c) => ({
key: c.categoryId,
label: c.categoryName,
value: c.totalMinutes,
color: c.color,
}))}
formatTooltip={(s, pct) => `${s.label}: ${formatMinutes(s.value)} (${pct.toFixed(1)}%)`}
/>
<div className="flex-1 space-y-3 w-full">
{data.categoryStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((c) => {
const total = data.categoryStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = total > 0 ? (c.totalMinutes / total) * 100 : 0;
return (
<div key={c.categoryId} className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shrink-0"
style={{ background: `${c.color}1a`, color: c.color }}
>
<CategoryIcon iconName={c.icon} color={c.color} size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-text-main truncate">{c.categoryName}</span>
<span className="text-xs font-black text-text-muted ml-2 shrink-0">
{formatMinutes(c.totalMinutes)} ({pct.toFixed(1)}%)
</span>
</div>
<div className="h-1.5 w-full bg-surface-muted rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
className="h-full rounded-full"
style={{ background: c.color }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Project Breakdown */} {/* Project Breakdown */}
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm"> <section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">

View file

@ -23,6 +23,9 @@ import {
type OrgProjectStats, type OrgProjectStats,
} from "../services/orgDashboardService"; } from "../services/orgDashboardService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { PieChart as SharedPieChart } from "../components/PieChart";
import { CategoryIcon } from "../components/CategoryIcon";
import { Tag } from "lucide-react";
const CHART_COLORS = [ const CHART_COLORS = [
"#7C3AED", "#7C3AED",
@ -372,6 +375,67 @@ export const OrgDashboardPage: React.FC = () => {
</section> </section>
</div> </div>
{/* Category Pie */}
{data.categoryStats.length > 0 && (
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
<div className="flex items-center gap-2 mb-8">
<Tag size={20} className="text-brand" />
<h2 className="text-xl font-black text-text-main">Tarefas por Categoria</h2>
</div>
<div className="flex flex-col sm:flex-row items-center gap-8">
<SharedPieChart
size={200}
centerLabel="categorias"
centerValue={data.categoryStats.length}
slices={data.categoryStats.map((c) => ({
key: c.categoryId,
label: c.categoryName,
value: c.totalMinutes,
color: c.color,
}))}
formatTooltip={(s, pct) => `${s.label}: ${formatMinutes(s.value)} (${pct.toFixed(1)}%)`}
/>
<div className="flex-1 space-y-3 w-full">
{data.categoryStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((c) => {
const totalCat = data.categoryStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = totalCat > 0 ? ((c.totalMinutes / totalCat) * 100).toFixed(1) : "0";
return (
<div key={c.categoryId} className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shrink-0"
style={{ background: `${c.color}1a`, color: c.color }}
>
<CategoryIcon iconName={c.icon} color={c.color} size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-text-main truncate">{c.categoryName}</span>
<span className="text-xs font-black text-text-muted ml-2 shrink-0">
{formatMinutes(c.totalMinutes)} ({pct}%)
</span>
</div>
<div className="h-1.5 w-full bg-surface-muted rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
className="h-full rounded-full"
style={{ background: c.color }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
)}
{/* Members Section */} {/* Members Section */}
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm"> <section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
<div className="flex items-center gap-2 mb-8"> <div className="flex items-center gap-2 mb-8">

View file

@ -15,11 +15,14 @@ import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService' import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import { categoryService } from '../services/categoryService'
import type { Category } from '../types/category'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { TaskResponse, TaskStatus } from '../types/task' import type { TaskResponse, TaskStatus } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar' import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar' import type { TaskFilters } from '../components/TaskFilterBar'
import { CategoryBadge } from '../components/CategoryBadge'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [ const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
@ -33,6 +36,7 @@ export function TaskBoardPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([]) const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS) const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate() const navigate = useNavigate()
const { activeOrg } = useAuth() const { activeOrg } = useAuth()
@ -44,7 +48,8 @@ export function TaskBoardPage() {
const response = await taskService.getMyTasks({ const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined, projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined,
status: currentFilters.statusFilter !== 'ALL' ? currentFilters.statusFilter : undefined, statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
}) })
setTasks(response) setTasks(response)
} catch (error) { } catch (error) {
@ -58,6 +63,7 @@ export function TaskBoardPage() {
void loadTasks(filters) void loadTasks(filters)
if (activeOrg) { if (activeOrg) {
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
} }
}, []) }, [])
@ -138,6 +144,7 @@ export function TaskBoardPage() {
filters={filters} filters={filters}
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
projects={projects} projects={projects}
categories={categories}
isLoading={isLoading} isLoading={isLoading}
onRefresh={() => void loadTasks(filters)} onRefresh={() => void loadTasks(filters)}
/> />
@ -207,9 +214,12 @@ export function TaskBoardPage() {
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} /> <TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted"> <div className="flex items-center gap-2">
<AlertCircle size={12} className="text-brand" /> <div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
{task.project?.name || 'Geral'} <AlertCircle size={12} className="text-brand" />
{task.project?.name || 'Geral'}
</div>
<CategoryBadge category={task.category} />
</div> </div>
<span className="font-mono text-[10px] font-bold text-text-muted/50"> <span className="font-mono text-[10px] font-bold text-text-muted/50">
#{task.id} #{task.id}

View file

@ -17,11 +17,14 @@ import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService' import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import { categoryService } from '../services/categoryService'
import type { Category } from '../types/category'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { TaskResponse } from '../types/task' import type { TaskResponse } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar' import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar' import type { TaskFilters } from '../components/TaskFilterBar'
import { CategoryBadge } from '../components/CategoryBadge'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
function getStatusConfig(status: TaskResponse['status']) { function getStatusConfig(status: TaskResponse['status']) {
@ -57,6 +60,7 @@ export function TaskListPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([]) const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS) const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate() const navigate = useNavigate()
const { activeOrg } = useAuth() const { activeOrg } = useAuth()
@ -68,7 +72,8 @@ export function TaskListPage() {
const response = await taskService.getMyTasks({ const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined, projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined,
status: currentFilters.statusFilter !== 'ALL' ? currentFilters.statusFilter : undefined, statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
}) })
setTasks(response) setTasks(response)
} catch (error) { } catch (error) {
@ -82,6 +87,7 @@ export function TaskListPage() {
void loadTasks(filters) void loadTasks(filters)
if (activeOrg) { if (activeOrg) {
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
} }
}, []) }, [])
@ -132,6 +138,7 @@ export function TaskListPage() {
filters={filters} filters={filters}
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
projects={projects} projects={projects}
categories={categories}
isLoading={isLoading} isLoading={isLoading}
onRefresh={() => void loadTasks(filters)} onRefresh={() => void loadTasks(filters)}
/> />
@ -184,6 +191,12 @@ export function TaskListPage() {
<Tag size={14} className="text-brand" /> <Tag size={14} className="text-brand" />
{task.project?.name || 'Geral'} {task.project?.name || 'Geral'}
</div> </div>
{task.category && (
<>
<div className="h-4 w-px bg-border-soft" />
<CategoryBadge category={task.category} />
</>
)}
<div className="h-4 w-px bg-border-soft" /> <div className="h-4 w-px bg-border-soft" />
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted"> <div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
<Calendar size={14} className="text-accent" /> <Calendar size={14} className="text-accent" />

View file

@ -11,6 +11,7 @@ import { JoinOrganizationPage } from "./pages/JoinOrganizationPage";
import { OrganizationAdminPage } from "./pages/OrganizationAdminPage"; import { OrganizationAdminPage } from "./pages/OrganizationAdminPage";
import { MyRequestsPage } from "./pages/MyRequestsPage"; import { MyRequestsPage } from "./pages/MyRequestsPage";
import { ProjectsPage } from "./pages/ProjectsPage"; import { ProjectsPage } from "./pages/ProjectsPage";
import { CategoriesPage } from "./pages/CategoriesPage";
import { OrganizationsPage } from "./pages/OrganizationsPage"; import { OrganizationsPage } from "./pages/OrganizationsPage";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/DashboardPage";
import { OrgDashboardPage } from "./pages/OrgDashboardPage"; import { OrgDashboardPage } from "./pages/OrgDashboardPage";
@ -73,6 +74,14 @@ export const router = createBrowserRouter([
</RoleGate> </RoleGate>
), ),
}, },
{
path: "categories",
element: (
<RoleGate allowedRoles={["OWNER", "ADMIN"]} message="Apenas administradores e proprietários podem gerenciar categorias.">
<CategoriesPage />
</RoleGate>
),
},
{ {
path: "organizations", path: "organizations",
element: <OrganizationsPage />, element: <OrganizationsPage />,

View file

@ -0,0 +1,22 @@
import { apiFetch } from '../lib/api'
import type { Category, CategoryPayload } from '../types/category'
export const categoryService = {
listByOrganization: async (orgId: number): Promise<Category[]> => {
return apiFetch<Category[]>(`/organizations/${orgId}/categories`)
},
create: async (orgId: number, payload: CategoryPayload): Promise<Category> => {
return apiFetch<Category>(`/organizations/${orgId}/categories`, {
method: 'POST',
body: payload,
})
},
update: async (orgId: number, categoryId: number, payload: CategoryPayload): Promise<Category> => {
return apiFetch<Category>(`/organizations/${orgId}/categories/${categoryId}`, {
method: 'PUT',
body: payload,
})
},
}

View file

@ -7,9 +7,18 @@ export interface ProjectStats {
totalMinutes: number totalMinutes: number
} }
export interface CategoryStats {
categoryId: number
categoryName: string
color: string
icon: string
totalMinutes: number
}
export interface DashboardData { export interface DashboardData {
totalMinutesWorked: number totalMinutesWorked: number
projectStats: ProjectStats[] projectStats: ProjectStats[]
categoryStats: CategoryStats[]
yesterdayTasks: TaskResponse[] yesterdayTasks: TaskResponse[]
finishedTasksInPeriod: TaskResponse[] finishedTasksInPeriod: TaskResponse[]
} }
@ -19,7 +28,7 @@ export const dashboardService = {
const params = new URLSearchParams() const params = new URLSearchParams()
if (startDate) params.append('startDate', startDate) if (startDate) params.append('startDate', startDate)
if (endDate) params.append('endDate', endDate) if (endDate) params.append('endDate', endDate)
return apiFetch<DashboardData>(`/dashboard?${params.toString()}`) return apiFetch<DashboardData>(`/dashboard?${params.toString()}`)
} }
} }

View file

@ -6,6 +6,14 @@ export interface OrgProjectStats {
totalMinutes: number totalMinutes: number
} }
export interface OrgCategoryStats {
categoryId: number
categoryName: string
color: string
icon: string
totalMinutes: number
}
export interface OrgTopTask { export interface OrgTopTask {
taskId: number taskId: number
title: string title: string
@ -23,6 +31,7 @@ export interface OrgMemberStats {
export interface OrgDashboardData { export interface OrgDashboardData {
projectStats: OrgProjectStats[] projectStats: OrgProjectStats[]
categoryStats: OrgCategoryStats[]
topTasks: OrgTopTask[] topTasks: OrgTopTask[]
memberStats: OrgMemberStats[] memberStats: OrgMemberStats[]
} }
@ -41,6 +50,14 @@ export interface MemberProjectStats {
totalMinutes: number totalMinutes: number
} }
export interface MemberCategoryStats {
categoryId: number
categoryName: string
color: string
icon: string
totalMinutes: number
}
export interface MemberDetailData { export interface MemberDetailData {
userId: number userId: number
userName: string userName: string
@ -49,6 +66,7 @@ export interface MemberDetailData {
groupedBy: 'DAY' | 'MONTH' groupedBy: 'DAY' | 'MONTH'
periodStats: PeriodStats[] periodStats: PeriodStats[]
projectStats: MemberProjectStats[] projectStats: MemberProjectStats[]
categoryStats: MemberCategoryStats[]
} }
export const orgDashboardService = { export const orgDashboardService = {

View file

@ -1,21 +1,35 @@
import { apiFetch } from '../lib/api' import { apiFetch } from '../lib/api'
import type { TaskResponse, TaskStatus } from '../types/task' import type { TaskResponse, TaskStatus } from '../types/task'
export interface GetMyTasksFilters {
text?: string
projectId?: number
statuses?: TaskStatus[]
categoryIds?: number[]
}
export const taskService = { export const taskService = {
async getTasks(): Promise<TaskResponse[]> { async getTasks(): Promise<TaskResponse[]> {
return apiFetch<TaskResponse[]>('/tasks') return apiFetch<TaskResponse[]>('/tasks')
}, },
async getMyTasks(filters?: { text?: string; projectId?: number; status?: TaskStatus }): Promise<TaskResponse[]> { async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters?.text) params.set('text', filters.text) if (filters?.text) params.set('text', filters.text)
if (filters?.projectId) params.set('projectId', String(filters.projectId)) if (filters?.projectId) params.set('projectId', String(filters.projectId))
if (filters?.status) params.set('status', filters.status) filters?.statuses?.forEach((s) => params.append('status', s))
filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
const query = params.toString() const query = params.toString()
return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`) return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`)
}, },
async createTask(task: { title: string; description?: string; projectId?: number | null; status?: TaskStatus }): Promise<TaskResponse> { async createTask(task: {
title: string
description?: string
projectId?: number | null
categoryId?: number | null
status?: TaskStatus
}): Promise<TaskResponse> {
return apiFetch<TaskResponse>('/tasks', { return apiFetch<TaskResponse>('/tasks', {
method: 'POST', method: 'POST',
body: task, body: task,
@ -45,7 +59,7 @@ export const taskService = {
return apiFetch<TaskResponse>(`/tasks/${id}`) return apiFetch<TaskResponse>(`/tasks/${id}`)
}, },
async updateTask(id: number, task: Partial<TaskResponse>): Promise<TaskResponse> { async updateTask(id: number, task: Partial<TaskResponse> & { projectId?: number | null; categoryId?: number | null }): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}`, { return apiFetch<TaskResponse>(`/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: task, body: task,

View file

@ -0,0 +1,12 @@
export type Category = {
id: number
name: string
color: string
icon: string
}
export type CategoryPayload = {
name: string
color: string
icon: string
}

View file

@ -19,6 +19,7 @@ export type CreateTaskPayload = {
description: string description: string
status: TaskStatus status: TaskStatus
projectId: number | null projectId: number | null
categoryId: number | null
} }
export type TaskHistoryItem = { export type TaskHistoryItem = {
@ -32,12 +33,20 @@ export type TaskProject = {
name: string name: string
} }
export type TaskCategory = {
id: number
name: string
color: string
icon: string
}
export type TaskResponse = { export type TaskResponse = {
id: number id: number
title: string title: string
description: string description: string
status: TaskStatus status: TaskStatus
project: TaskProject | null project: TaskProject | null
category: TaskCategory | null
finishedAt?: string finishedAt?: string
history?: TaskHistoryItem[] history?: TaskHistoryItem[]
} }