From 2fdb3754620cf99e9aba38014ecafc254fb6c5f7 Mon Sep 17 00:00:00 2001 From: Gabriel Santana de Menezes Date: Fri, 15 May 2026 16:03:04 -0400 Subject: [PATCH] feat: adicionar categorias para tasks (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../beetask/entities/Category.java | 62 ++++ .../beetask/entities/task/Task.java | 10 + .../config/CategoryUseCaseConfig.java | 32 ++ .../frameworks/config/TaskUseCaseConfig.java | 11 +- .../persistence/entities/CategoryEntity.java | 64 ++++ .../persistence/entities/TaskEntity.java | 11 + .../mapper/CategoryEntityMapper.java | 31 ++ .../persistence/mapper/TaskEntityMapper.java | 2 + .../repository/CategoryRepositoryImpl.java | 60 ++++ .../repository/TaskRepositoryImpl.java | 18 +- .../category/CategoryController.java | 77 +++++ .../category/CategoryControllerMapper.java | 21 ++ .../controllers/category/CategoryRequest.java | 3 + .../category/CategoryResponse.java | 3 + .../MemberDetailControllerMapper.java | 4 + .../orgdashboard/MemberDetailResponse.java | 4 +- .../OrgCategoryStatsResponse.java | 3 + .../OrgDashboardControllerMapper.java | 3 + .../orgdashboard/OrgDashboardResponse.java | 1 + .../controllers/task/CreateTaskRequest.java | 1 + .../controllers/task/CreateTaskResponse.java | 2 + .../controllers/task/ListMyTasksRequest.java | 7 +- .../task/TaskControllerMapper.java | 18 +- .../usecase/category/CategoryMapper.java | 14 + .../usecase/category/CategoryOutput.java | 3 + .../category/create/CreateCategoryInput.java | 3 + .../create/CreateCategoryUseCase.java | 56 ++++ .../category/list/ListCategoriesUseCase.java | 25 ++ .../category/update/UpdateCategoryInput.java | 4 + .../update/UpdateCategoryUseCase.java | 54 ++++ .../dashboard/DashboardCategoryStats.java | 9 + .../usecase/dashboard/DashboardOutput.java | 1 + .../usecase/dashboard/DashboardUseCase.java | 12 + .../orgdashboard/OrgCategoryStats.java | 3 + .../orgdashboard/OrgDashboardOutput.java | 1 + .../orgdashboard/OrgDashboardUseCase.java | 12 + .../memberdetail/MemberDetailMapper.java | 6 +- .../memberdetail/MemberDetailOutput.java | 4 +- .../memberdetail/MemberDetailUseCase.java | 14 +- .../repository/CategoryRepository.java | 13 + .../usecase/repository/TaskRepository.java | 3 +- .../usecase/task/create/CreateTaskInput.java | 1 + .../usecase/task/create/CreateTaskMapper.java | 11 +- .../usecase/task/create/CreateTaskOutput.java | 4 +- .../task/create/CreateTaskUseCase.java | 16 +- .../task/listall/ListMyTasksInput.java | 5 +- .../task/listall/ListMyTasksUseCase.java | 3 +- .../usecase/task/update/UpdateTaskInput.java | 1 + .../task/update/UpdateTaskUseCase.java | 18 +- .../db/migration/V2__add_categories.sql | 18 ++ frontend/src/components/AppShell.tsx | 8 + frontend/src/components/CategoryBadge.tsx | 22 ++ frontend/src/components/CategoryIcon.tsx | 16 + frontend/src/components/IconPicker.tsx | 88 ++++++ frontend/src/components/MemberDetailModal.tsx | 102 ++++++ frontend/src/components/MultiSelectChips.tsx | 70 +++++ frontend/src/components/PieChart.tsx | 121 +++++++ frontend/src/components/TaskFilterBar.tsx | 133 ++++---- frontend/src/pages/CategoriesPage.tsx | 294 ++++++++++++++++++ frontend/src/pages/CreateTaskPage.tsx | 55 +++- frontend/src/pages/DashboardPage.tsx | 61 ++++ frontend/src/pages/OrgDashboardPage.tsx | 64 ++++ frontend/src/pages/TaskBoardPage.tsx | 18 +- frontend/src/pages/TaskListPage.tsx | 15 +- frontend/src/router.tsx | 9 + frontend/src/services/categoryService.ts | 22 ++ frontend/src/services/dashboardService.ts | 11 +- frontend/src/services/orgDashboardService.ts | 18 ++ frontend/src/services/taskService.ts | 24 +- frontend/src/types/category.ts | 12 + frontend/src/types/task.ts | 9 + 71 files changed, 1837 insertions(+), 97 deletions(-) create mode 100644 backend/src/main/java/net/beetechgroup/beetask/entities/Category.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/CategoryEntityMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/CategoryRepositoryImpl.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryControllerMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryRequest.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryResponse.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgCategoryStatsResponse.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryOutput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryInput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryInput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardCategoryStats.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgCategoryStats.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/repository/CategoryRepository.java create mode 100644 backend/src/main/resources/db/migration/V2__add_categories.sql create mode 100644 frontend/src/components/CategoryBadge.tsx create mode 100644 frontend/src/components/CategoryIcon.tsx create mode 100644 frontend/src/components/IconPicker.tsx create mode 100644 frontend/src/components/MultiSelectChips.tsx create mode 100644 frontend/src/components/PieChart.tsx create mode 100644 frontend/src/pages/CategoriesPage.tsx create mode 100644 frontend/src/services/categoryService.ts create mode 100644 frontend/src/types/category.ts diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/Category.java b/backend/src/main/java/net/beetechgroup/beetask/entities/Category.java new file mode 100644 index 0000000..6d07b87 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/Category.java @@ -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") + + '}'; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java index 380334e..e9234f6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.User; @@ -14,6 +15,7 @@ public class Task { private String description; private TaskStatus status; private Project project; + private Category category; private User user; private LocalDateTime finishedAt; private List history = new ArrayList<>(); @@ -74,6 +76,14 @@ public class Task { this.project = project; } + public Category getCategory() { + return category; + } + + public void setCategory(Category category) { + this.category = category; + } + public User getUser() { return user; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java new file mode 100644 index 0000000..d625825 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/CategoryUseCaseConfig.java @@ -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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java index ed271fa..df99e08 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java @@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config; import jakarta.enterprise.context.ApplicationScoped; 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.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -18,8 +19,9 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase; public class TaskUseCaseConfig { @Produces - public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { - return new CreateTaskUseCase(taskRepository, projectRepository, userRepository); + public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, + CategoryRepository categoryRepository, UserRepository userRepository) { + return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository); } @Produces @@ -48,8 +50,9 @@ public class TaskUseCaseConfig { } @Produces - public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) { - return new UpdateTaskUseCase(taskRepository, projectRepository, userRepository); + public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, + CategoryRepository categoryRepository, UserRepository userRepository) { + return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository); } @Produces diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java new file mode 100644 index 0000000..51d00ec --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/CategoryEntity.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java index c2a0b21..2733aec 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java @@ -33,6 +33,9 @@ public class TaskEntity { @JoinColumn(name = "project_id") private ProjectEntity project; @ManyToOne + @JoinColumn(name = "category_id") + private CategoryEntity category; + @ManyToOne @JoinColumn(name = "user_id") private UserEntity user; @OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true) @@ -94,6 +97,14 @@ public class TaskEntity { this.project = project; } + public CategoryEntity getCategory() { + return category; + } + + public void setCategory(CategoryEntity category) { + this.category = category; + } + public UserEntity getUser() { return user; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/CategoryEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/CategoryEntityMapper.java new file mode 100644 index 0000000..20af292 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/CategoryEntityMapper.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java index d64b094..56441bc 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java @@ -17,6 +17,7 @@ public class TaskEntityMapper { entity.setStatus(task.getStatus()); entity.setFinishedAt(task.getFinishedAt()); entity.setProject(ProjectEntityMapper.toEntity(task.getProject())); + entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory())); entity.setUser(UserEntityMapper.toEntity(task.getUser())); entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> { TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h); @@ -36,6 +37,7 @@ public class TaskEntityMapper { task.setStatus(entity.getStatus()); task.setFinishedAt(entity.getFinishedAt()); task.setProject(ProjectEntityMapper.toDomain(entity.getProject())); + task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory())); task.setUser(UserEntityMapper.toDomain(entity.getUser())); task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList())); diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/CategoryRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/CategoryRepositoryImpl.java new file mode 100644 index 0000000..489d347 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/CategoryRepositoryImpl.java @@ -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 { + 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 findCategoryById(Long id) { + Optional category = findByIdOptional(id).map(CategoryEntityMapper::toDomain); + if (category.isEmpty()) { + LOGGER.warnf("Category %d was not found", id); + } + return category; + } + + @Override + public List findByOrganizationId(Long organizationId) { + List 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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index da4d98a..8fb7603 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -52,7 +52,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status) { + public List findTasksByUserFiltered(String email, String text, Long projectId, + List statuses, List categoryIds) { StringBuilder query = new StringBuilder("user.email = ?1"); List params = new ArrayList<>(); params.add(email); @@ -70,13 +71,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository 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; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java new file mode 100644 index 0000000..0e317d1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryController.java @@ -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 listCategories(@PathParam("orgId") Long orgId) { + List 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))); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryControllerMapper.java new file mode 100644 index 0000000..edcce8c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryControllerMapper.java @@ -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()); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryRequest.java new file mode 100644 index 0000000..0f846a2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryRequest.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.category; + +public record CategoryRequest(String name, String color, String icon) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryResponse.java new file mode 100644 index 0000000..ef46be1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/category/CategoryResponse.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.category; + +public record CategoryResponse(Long id, String name, String color, String icon) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java index 9e7b553..8c8da84 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java @@ -18,6 +18,10 @@ public class MemberDetailControllerMapper { output.projectStats().stream() .map(p -> new MemberDetailResponse.ProjectStatsResponse( 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() ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java index 92d714f..1bb078a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java @@ -9,8 +9,10 @@ public record MemberDetailResponse( String userPhoto, String groupedBy, List periodStats, - List projectStats + List projectStats, + List categoryStats ) { public record PeriodStatsResponse(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {} public record ProjectStatsResponse(Long projectId, String projectName, long totalMinutes) {} + public record CategoryStatsResponse(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {} } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgCategoryStatsResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgCategoryStatsResponse.java new file mode 100644 index 0000000..f36b05c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgCategoryStatsResponse.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard; + +public record OrgCategoryStatsResponse(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardControllerMapper.java index d392346..cfff498 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardControllerMapper.java @@ -11,6 +11,9 @@ public class OrgDashboardControllerMapper { output.projectStats().stream() .map(p -> new OrgProjectStatsResponse(p.projectId(), p.projectName(), p.totalMinutes())) .toList(), + output.categoryStats().stream() + .map(c -> new OrgCategoryStatsResponse(c.categoryId(), c.categoryName(), c.color(), c.icon(), c.totalMinutes())) + .toList(), output.topTasks().stream() .map(t -> new OrgTopTaskResponse(t.taskId(), t.title(), t.projectName(), t.totalMinutes())) .toList(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardResponse.java index b80c05d..6f669a0 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardResponse.java @@ -4,6 +4,7 @@ import java.util.List; public record OrgDashboardResponse( List projectStats, + List categoryStats, List topTasks, List memberStats ) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java index 79b3ea4..ea197fa 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java @@ -9,5 +9,6 @@ public record CreateTaskRequest( String description, TaskStatus status, Long projectId, + Long categoryId, List history) { } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java index e5eb8fb..99a6227 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java @@ -11,10 +11,12 @@ public record CreateTaskResponse( String description, TaskStatus status, ProjectResponse project, + CategoryResponse category, LocalDateTime finishedAt, List history) { public record ProjectResponse(Long id, String name) {} + public record CategoryResponse(Long id, String name, String color, String icon) {} } record TaskHistoryItemResponse( diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java index 111c1d7..7475e5b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java @@ -1,5 +1,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; +import java.util.List; + import jakarta.ws.rs.QueryParam; import net.beetechgroup.beetask.entities.task.TaskStatus; @@ -12,5 +14,8 @@ public class ListMyTasksRequest { public Long projectId; @QueryParam("status") - public TaskStatus status; + public List statuses; + + @QueryParam("categoryId") + public List categoryIds; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index 925cc76..c2291bb 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -12,7 +12,7 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput; public class TaskControllerMapper { 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) { @@ -21,6 +21,7 @@ public class TaskControllerMapper { request.description(), request.status(), request.projectId(), + request.categoryId(), userEmail, Objects.nonNull(request.history()) ? request.history().stream() .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) @@ -34,9 +35,19 @@ public class TaskControllerMapper { output.title(), output.description(), 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.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.status(), request.projectId(), + request.categoryId(), userEmail, Objects.nonNull(request.history()) ? request.history().stream() .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryMapper.java new file mode 100644 index 0000000..cb406ba --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryMapper.java @@ -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()); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryOutput.java new file mode 100644 index 0000000..473141f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/CategoryOutput.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.category; + +public record CategoryOutput(Long id, String name, String color, String icon) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryInput.java new file mode 100644 index 0000000..367bb4c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryInput.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.category.create; + +public record CreateCategoryInput(String userEmail, Long organizationId, String name, String color, String icon) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryUseCase.java new file mode 100644 index 0000000..7579740 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/create/CreateCategoryUseCase.java @@ -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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java new file mode 100644 index 0000000..e64a42c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/list/ListCategoriesUseCase.java @@ -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 execute(Long organizationId) { + List categories = categoryRepository.findByOrganizationId(organizationId).stream() + .map(CategoryMapper::toCategoryOutput) + .toList(); + LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId); + return categories; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryInput.java new file mode 100644 index 0000000..7428632 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryInput.java @@ -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) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryUseCase.java new file mode 100644 index 0000000..09b978c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/category/update/UpdateCategoryUseCase.java @@ -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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardCategoryStats.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardCategoryStats.java new file mode 100644 index 0000000..d60b0a2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardCategoryStats.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.usecase.dashboard; + +public record DashboardCategoryStats( + Long categoryId, + String categoryName, + String color, + String icon, + Long totalMinutes +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardOutput.java index 95ad680..170dc7d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardOutput.java @@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; public record DashboardOutput( Long totalMinutesWorked, List projectStats, + List categoryStats, List yesterdayTasks, List finishedTasksInPeriod ) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java index f1a0f5b..ae6bece 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/dashboard/DashboardUseCase.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.usecase.repository.TaskRepository; @@ -43,6 +44,7 @@ public class DashboardUseCase { long totalMinutes = 0; Map projectStatsMap = new HashMap<>(); + Map categoryStatsMap = new HashMap<>(); for (Task task : workedTasks) { long taskMinutesInPeriod = task.getHistory().stream() @@ -63,11 +65,21 @@ public class DashboardUseCase { 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( totalMinutes, new ArrayList<>(projectStatsMap.values()), + new ArrayList<>(categoryStatsMap.values()), yesterdayTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(), finishedTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList() ); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgCategoryStats.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgCategoryStats.java new file mode 100644 index 0000000..2c5313d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgCategoryStats.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +public record OrgCategoryStats(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java index 8914eb6..03070ce 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java @@ -4,6 +4,7 @@ import java.util.List; public record OrgDashboardOutput( List projectStats, + List categoryStats, List topTasks, List memberStats ) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java index c5796b8..c73bc94 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.organization.UserOrganization; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.task.Task; @@ -54,6 +55,7 @@ public class OrgDashboardUseCase { ); Map projectStatsMap = new HashMap<>(); + Map categoryStatsMap = new HashMap<>(); Map memberMinutesMap = new HashMap<>(); for (Task task : workedTasks) { @@ -66,6 +68,15 @@ public class OrgDashboardUseCase { projectStatsMap.put(projectId, new OrgProjectStats( 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())) { memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum); } @@ -104,6 +115,7 @@ public class OrgDashboardUseCase { OrgDashboardOutput output = new OrgDashboardOutput( new ArrayList<>(projectStatsMap.values()), + new ArrayList<>(categoryStatsMap.values()), topTasks, memberStats ); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java index 522af8e..d73f582 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java @@ -9,7 +9,8 @@ public class MemberDetailMapper { public static MemberDetailOutput toOutput(UserOrganization member, String groupedBy, List periodStats, - List projectStats) { + List projectStats, + List categoryStats) { return new MemberDetailOutput( member.getUser().getId(), member.getUser().getName(), @@ -17,7 +18,8 @@ public class MemberDetailMapper { member.getUser().getPhoto(), groupedBy, periodStats, - projectStats + projectStats, + categoryStats ); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java index 520b60b..e9bada5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java @@ -9,8 +9,10 @@ public record MemberDetailOutput( String userPhoto, String groupedBy, List periodStats, - List projectStats + List projectStats, + List categoryStats ) { public record PeriodStats(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {} public record ProjectStats(Long projectId, String projectName, long totalMinutes) {} + public record CategoryStats(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {} } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java index 0435d1e..e8637a5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java @@ -1,5 +1,6 @@ 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.task.Task; import net.beetechgroup.beetask.entities.task.TaskHistoryItem; @@ -57,6 +58,7 @@ public class MemberDetailUseCase { List finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end); Map projectStatsMap = new HashMap<>(); + Map categoryStatsMap = new HashMap<>(); Map minutesPerDay = new HashMap<>(); Map minutesPerMonth = new HashMap<>(); @@ -81,6 +83,15 @@ public class MemberDetailUseCase { new MemberDetailOutput.ProjectStats(projectId, projectName, 0L)); projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats( 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, - 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", input.memberId(), input.organizationId(), workedTasks.size(), finishedTasks.size()); return output; diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/CategoryRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/CategoryRepository.java new file mode 100644 index 0000000..be826e1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/CategoryRepository.java @@ -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 findCategoryById(Long id); + List findByOrganizationId(Long organizationId); + boolean existsByNameInOrganization(Long organizationId, String name, Long excludeId); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index a921518..cc5a549 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -13,7 +13,8 @@ public interface TaskRepository { List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); - List findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status); + List findTasksByUserFiltered(String email, String text, Long projectId, + List statuses, List categoryIds); List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java index 8d66c27..d317bf4 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java @@ -9,6 +9,7 @@ public record CreateTaskInput( String description, TaskStatus status, Long projectId, + Long categoryId, String userEmail, List history) { } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java index 316d16d..75fe2ec 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java @@ -11,7 +11,16 @@ public class CreateTaskMapper { task.getTitle(), task.getDescription(), 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.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList()); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java index c87196f..43b531f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java @@ -10,8 +10,10 @@ public record CreateTaskOutput( String description, TaskStatus status, ProjectOutput project, + CategoryOutput category, LocalDateTime finishedAt, List history) { public record ProjectOutput(Long id, String name) {} -} \ No newline at end of file + public record CategoryOutput(Long id, String name, String color, String icon) {} +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java index a3c136d..216e309 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java @@ -2,9 +2,11 @@ package net.beetechgroup.beetask.usecase.task.create; import java.util.List; import java.util.Objects; +import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.task.Task; 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.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -14,11 +16,14 @@ public class CreateTaskUseCase { private static final Logger LOGGER = Logger.getLogger(CreateTaskUseCase.class); private final TaskRepository taskRepository; private final ProjectRepository projectRepository; + private final CategoryRepository categoryRepository; 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.projectRepository = projectRepository; + this.categoryRepository = categoryRepository; this.userRepository = userRepository; } @@ -38,6 +43,15 @@ public class CreateTaskUseCase { 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()) .orElseThrow(() -> { LOGGER.warnf("Task creation failed because user %s was not found", input.userEmail()); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java index e597801..c6dfbdc 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java @@ -1,5 +1,8 @@ package net.beetechgroup.beetask.usecase.task.listall; +import java.util.List; + import net.beetechgroup.beetask.entities.task.TaskStatus; -public record ListMyTasksInput(String email, String text, Long projectId, TaskStatus status) {} +public record ListMyTasksInput(String email, String text, Long projectId, + List statuses, List categoryIds) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java index a5119c5..cd413d7 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java @@ -17,7 +17,8 @@ public class ListMyTasksUseCase { } public List execute(ListMyTasksInput input) { - List tasks = taskRepository.findTasksByUserFiltered(input.email(), input.text(), input.projectId(), input.status()) + List tasks = taskRepository.findTasksByUserFiltered( + input.email(), input.text(), input.projectId(), input.statuses(), input.categoryIds()) .stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email()); return tasks; diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java index 327c905..ba7de67 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java @@ -9,6 +9,7 @@ public record UpdateTaskInput( String description, TaskStatus status, Long projectId, + Long categoryId, String userEmail, List history) { } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java index 8c9f86a..e5107c4 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java @@ -2,9 +2,11 @@ package net.beetechgroup.beetask.usecase.task.update; import java.util.List; import java.util.Objects; +import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.task.Task; 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.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -16,11 +18,14 @@ public class UpdateTaskUseCase { private static final Logger LOGGER = Logger.getLogger(UpdateTaskUseCase.class); private final TaskRepository taskRepository; private final ProjectRepository projectRepository; + private final CategoryRepository categoryRepository; 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.projectRepository = projectRepository; + this.categoryRepository = categoryRepository; this.userRepository = userRepository; } @@ -51,6 +56,17 @@ public class UpdateTaskUseCase { 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())) { List updatedHistory = input.history().stream().map(hInput -> { if (Objects.nonNull(hInput.id())) { diff --git a/backend/src/main/resources/db/migration/V2__add_categories.sql b/backend/src/main/resources/db/migration/V2__add_categories.sql new file mode 100644 index 0000000..9d4c731 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_categories.sql @@ -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); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c42ebe..3186f3e 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -10,6 +10,7 @@ import { Clock, ChevronDown, FolderKanban, + Tag, BarChart3, PieChart, Search, @@ -267,6 +268,13 @@ export function AppShell() { )} + {isAdmin && ( + + + Categorias + + )} + {/* Equipe Dropdown */}
+ + {showLabel && category.name} + + ) +} diff --git a/frontend/src/components/CategoryIcon.tsx b/frontend/src/components/CategoryIcon.tsx new file mode 100644 index 0000000..6e453bd --- /dev/null +++ b/frontend/src/components/CategoryIcon.tsx @@ -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 + const IconComponent = (iconName && icons[iconName]) || Tag + return +} diff --git a/frontend/src/components/IconPicker.tsx b/frontend/src/components/IconPicker.tsx new file mode 100644 index 0000000..4ba9c87 --- /dev/null +++ b/frontend/src/components/IconPicker.tsx @@ -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 + 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 + + 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 ( +
+
+ + 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" + /> +
+
+ {filtered.map((name) => { + const Icon = icons[name] + if (!Icon) return null + const selected = value === name + return ( + + ) + })} + {filtered.length === 0 && ( +

+ Nenhum ícone encontrado. +

+ )} +
+ {value && ( +

+ Selecionado: {value} +

+ )} +
+ ) +} diff --git a/frontend/src/components/MemberDetailModal.tsx b/frontend/src/components/MemberDetailModal.tsx index 7eb02c7..6907b52 100644 --- a/frontend/src/components/MemberDetailModal.tsx +++ b/frontend/src/components/MemberDetailModal.tsx @@ -7,7 +7,9 @@ import { type MemberDetailData, type PeriodStats, type MemberProjectStats, + type MemberCategoryStats, } from "../services/orgDashboardService"; +import { CategoryIcon } from "./CategoryIcon"; 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"]; @@ -155,6 +157,69 @@ function BarChart({ ); } +function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) { + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + + const total = data.reduce((s, d) => s + d.totalMinutes, 0); + if (total === 0 || data.length === 0) { + return ( +
+ Sem dados +
+ ); + } + + 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 ( +
+ setTooltip(null)} + > + {slices.map((s, i) => ( + handleMouseMove(e, s.label)} + /> + ))} + + {tooltip && } +
+ ); +} + function MiniPieChart({ data }: { data: MemberProjectStats[] }) { const [tooltip, setTooltip] = useState(null); const svgRef = useRef(null); @@ -374,6 +439,43 @@ export const MemberDetailModal: React.FC = ({
+ {/* Pie chart — hours by category */} + {data.categoryStats.length > 0 && ( +
+
+ Horas por Categoria +
+
+
+ +
+
+ {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 ( +
+
+ +
+ {c.categoryName} + + {formatMinutes(c.totalMinutes)} ({pct}%) + +
+ ); + })} +
+
+
+ )} + {/* Pie chart — hours by project */} {data.projectStats.length > 0 && (
diff --git a/frontend/src/components/MultiSelectChips.tsx b/frontend/src/components/MultiSelectChips.tsx new file mode 100644 index 0000000..a049e10 --- /dev/null +++ b/frontend/src/components/MultiSelectChips.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import { cn } from '../lib/utils' + +export interface MultiSelectOption { + value: T + label: string + icon?: ReactNode + color?: string +} + +interface MultiSelectChipsProps { + options: MultiSelectOption[] + selected: T[] + onChange: (selected: T[]) => void + emptyLabel?: string +} + +export function MultiSelectChips({ + options, + selected, + onChange, + emptyLabel = 'Todos', +}: MultiSelectChipsProps) { + const toggle = (value: T) => { + if (selected.includes(value)) { + onChange(selected.filter((v) => v !== value)) + } else { + onChange([...selected, value]) + } + } + + const allSelected = selected.length === 0 + + return ( +
+ + {options.map((option) => { + const isSelected = selected.includes(option.value) + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/components/PieChart.tsx b/frontend/src/components/PieChart.tsx new file mode 100644 index 0000000..478dbc9 --- /dev/null +++ b/frontend/src/components/PieChart.tsx @@ -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(null) + const svgRef = useRef(null) + + const total = slices.reduce((sum, s) => sum + s.value, 0) + if (total === 0 || slices.length === 0) { + return ( +
+ Sem dados no período +
+ ) + } + + 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 ( +
+ setTooltip(null)} + > + {paths.map((slice, i) => ( + handleMouseMove(e, slice.tip)} + /> + ))} + {centerLabel && ( + + {centerLabel} + + )} + {centerValue !== undefined && ( + + {centerValue} + + )} + + {tooltip && ( +
+ {tooltip.content} +
+ )} +
+ ) +} diff --git a/frontend/src/components/TaskFilterBar.tsx b/frontend/src/components/TaskFilterBar.tsx index 9a6df2a..735a682 100644 --- a/frontend/src/components/TaskFilterBar.tsx +++ b/frontend/src/components/TaskFilterBar.tsx @@ -1,18 +1,22 @@ import { Filter, RefreshCw, Search } from 'lucide-react' -import { cn } from '../lib/utils' import type { TaskStatus } from '../types/task' import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task' import type { Project } from '../services/projectService' +import type { Category } from '../types/category' +import { MultiSelectChips } from './MultiSelectChips' +import { CategoryIcon } from './CategoryIcon' export interface TaskFilters { searchQuery: string - statusFilter: TaskStatus | 'ALL' + statuses: TaskStatus[] + categoryIds: number[] projectId: number | 'ALL' } export const DEFAULT_TASK_FILTERS: TaskFilters = { searchQuery: '', - statusFilter: 'ALL', + statuses: [], + categoryIds: [], projectId: 'ALL', } @@ -20,6 +24,7 @@ interface TaskFilterBarProps { filters: TaskFilters onFiltersChange: (filters: TaskFilters) => void projects: Project[] + categories: Category[] isLoading?: boolean onRefresh?: () => void } @@ -28,6 +33,7 @@ export function TaskFilterBar({ filters, onFiltersChange, projects, + categories, isLoading, onRefresh, }: TaskFilterBarProps) { @@ -35,66 +41,77 @@ export function TaskFilterBar({ onFiltersChange({ ...filters, ...partial }) return ( -
-
- - 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" - /> -
+
+
+
+ + 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" + /> +
- {projects.length > 0 && ( - - )} - -
- - {(['ALL', ...TASK_STATUS_OPTIONS] as const).map((status) => ( - - ))} + )}
- {onRefresh && ( - +
+ + Status + ({ value: s, label: TASK_STATUS_LABELS[s] }))} + selected={filters.statuses} + onChange={(statuses) => update({ statuses })} + /> +
+ + {categories.length > 0 && ( +
+ + Categorias + ({ + value: c.id, + label: c.name, + color: c.color, + icon: , + }))} + selected={filters.categoryIds} + onChange={(categoryIds) => update({ categoryIds })} + /> +
)}
) diff --git a/frontend/src/pages/CategoriesPage.tsx b/frontend/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..87f2e44 --- /dev/null +++ b/frontend/src/pages/CategoriesPage.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+
+ + Workspace +
+

+ Categorias em {activeOrg?.name ?? '...'} +

+

+ Crie categorias para classificar e medir o tempo das suas tarefas. +

+
+
+
+ + 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" + /> +
+ +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ + {filtered.map((category, index) => ( + +
+ +
+
+

{category.name}

+

+ {category.icon} · {category.color} +

+
+ +
+ ))} + +
+
+ )} + + {!isLoading && filtered.length === 0 && categories.length === 0 && ( +
+
+ +
+

Nenhuma categoria cadastrada

+

+ Crie sua primeira categoria para começar a classificar tarefas e medir tempo. +

+
+ )} + + + {showForm && ( +
+ + + +
+
+ +
+
+

+ {editing ? 'Editar categoria' : 'Nova categoria'} +

+

+ Defina nome, cor e ícone para identificar suas tarefas. +

+
+
+ +
+
+ + +
+ +
+ Ícone + setForm({ ...form, icon })} + color={form.color} + /> +
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/CreateTaskPage.tsx b/frontend/src/pages/CreateTaskPage.tsx index 6495ee5..e770997 100644 --- a/frontend/src/pages/CreateTaskPage.tsx +++ b/frontend/src/pages/CreateTaskPage.tsx @@ -21,9 +21,13 @@ import type { CreateTaskPayload, TaskResponse, TaskHistoryItem } from "../types/ import { useAuth } from "../contexts/AuthContext"; import { useEffect } from "react"; 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 & { +type FormState = Omit & { projectId: string; + categoryId: string; history: TaskHistoryItem[]; }; @@ -32,6 +36,7 @@ const initialFormState: FormState = { description: "", status: "NOT_STARTED", projectId: "", + categoryId: "", history: [], }; @@ -41,6 +46,7 @@ export function CreateTaskPage() { const [errorMessage, setErrorMessage] = useState(null); const [createdTask, setCreatedTask] = useState(null); const [projects, setProjects] = useState([]); + const [categories, setCategories] = useState([]); const { activeOrg } = useAuth(); const { id } = useParams(); const navigate = useNavigate(); @@ -56,6 +62,7 @@ export function CreateTaskPage() { description: data.description || "", status: data.status, projectId: data.project?.id?.toString() || "", + categoryId: data.category?.id?.toString() || "", history: data.history || [], }); } catch (error) { @@ -77,7 +84,17 @@ export function CreateTaskPage() { 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(); + loadCategories(); }, [activeOrg]); function updateField( @@ -103,6 +120,7 @@ export function CreateTaskPage() { description: form.description.trim(), status: form.status, projectId: form.projectId ? Number(form.projectId) : null, + categoryId: form.categoryId ? Number(form.categoryId) : null, history: form.history, }; @@ -233,8 +251,8 @@ export function CreateTaskPage() {
- {/* Project & Status Row */} -
+ {/* Project, Category & Status Row */} +