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:
parent
16529fafa5
commit
2fdb375462
71 changed files with 1837 additions and 97 deletions
|
|
@ -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") +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
|
||||||
|
|
||||||
|
public record CategoryRequest(String name, String color, String icon) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.category;
|
||||||
|
|
||||||
|
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard;
|
||||||
|
|
||||||
|
public record OrgCategoryStatsResponse(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.category;
|
||||||
|
|
||||||
|
public record CategoryOutput(Long id, String name, String color, String icon) {}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.category.create;
|
||||||
|
|
||||||
|
public record CreateCategoryInput(String userEmail, Long organizationId, String name, String color, String icon) {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.dashboard;
|
||||||
|
|
||||||
|
public record DashboardCategoryStats(
|
||||||
|
Long categoryId,
|
||||||
|
String categoryName,
|
||||||
|
String color,
|
||||||
|
String icon,
|
||||||
|
Long totalMinutes
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.orgdashboard;
|
||||||
|
|
||||||
|
public record OrgCategoryStats(Long categoryId, String categoryName, String color, String icon, long totalMinutes) {}
|
||||||
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
22
frontend/src/components/CategoryBadge.tsx
Normal file
22
frontend/src/components/CategoryBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
frontend/src/components/CategoryIcon.tsx
Normal file
16
frontend/src/components/CategoryIcon.tsx
Normal 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} />
|
||||||
|
}
|
||||||
88
frontend/src/components/IconPicker.tsx
Normal file
88
frontend/src/components/IconPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
70
frontend/src/components/MultiSelectChips.tsx
Normal file
70
frontend/src/components/MultiSelectChips.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
frontend/src/components/PieChart.tsx
Normal file
121
frontend/src/components/PieChart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
294
frontend/src/pages/CategoriesPage.tsx
Normal file
294
frontend/src/pages/CategoriesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 />,
|
||||||
|
|
|
||||||
22
frontend/src/services/categoryService.ts
Normal file
22
frontend/src/services/categoryService.ts
Normal 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
12
frontend/src/types/category.ts
Normal file
12
frontend/src/types/category.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue