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.Objects;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.Project;
|
||||
import net.beetechgroup.beetask.entities.User;
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ public class Task {
|
|||
private String description;
|
||||
private TaskStatus status;
|
||||
private Project project;
|
||||
private Category category;
|
||||
private User user;
|
||||
private LocalDateTime finishedAt;
|
||||
private List<TaskHistoryItem> history = new ArrayList<>();
|
||||
|
|
@ -74,6 +76,14 @@ public class Task {
|
|||
this.project = project;
|
||||
}
|
||||
|
||||
public Category getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public void setCategory(Category category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.inject.Produces;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
|
@ -18,8 +19,9 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
|
|||
public class TaskUseCaseConfig {
|
||||
|
||||
@Produces
|
||||
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) {
|
||||
return new CreateTaskUseCase(taskRepository, projectRepository, userRepository);
|
||||
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository) {
|
||||
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
@ -48,8 +50,9 @@ public class TaskUseCaseConfig {
|
|||
}
|
||||
|
||||
@Produces
|
||||
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) {
|
||||
return new UpdateTaskUseCase(taskRepository, projectRepository, userRepository);
|
||||
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository) {
|
||||
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
private ProjectEntity project;
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "category_id")
|
||||
private CategoryEntity category;
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id")
|
||||
private UserEntity user;
|
||||
@OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
|
|
@ -94,6 +97,14 @@ public class TaskEntity {
|
|||
this.project = project;
|
||||
}
|
||||
|
||||
public CategoryEntity getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public void setCategory(CategoryEntity category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public UserEntity getUser() {
|
||||
return user;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.setFinishedAt(task.getFinishedAt());
|
||||
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
||||
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
|
||||
entity.setUser(UserEntityMapper.toEntity(task.getUser()));
|
||||
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
|
||||
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
|
||||
|
|
@ -36,6 +37,7 @@ public class TaskEntityMapper {
|
|||
task.setStatus(entity.getStatus());
|
||||
task.setFinishedAt(entity.getFinishedAt());
|
||||
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
|
||||
task.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(email);
|
||||
|
|
@ -70,13 +71,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
params.add(projectId);
|
||||
idx++;
|
||||
}
|
||||
if (Objects.nonNull(status)) {
|
||||
query.append(" and status = ?").append(idx);
|
||||
params.add(status);
|
||||
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
||||
query.append(" and status in ?").append(idx);
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
.map(p -> new MemberDetailResponse.ProjectStatsResponse(
|
||||
p.projectId(), p.projectName(), p.totalMinutes()))
|
||||
.toList(),
|
||||
output.categoryStats().stream()
|
||||
.map(c -> new MemberDetailResponse.CategoryStatsResponse(
|
||||
c.categoryId(), c.categoryName(), c.color(), c.icon(), c.totalMinutes()))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ public record MemberDetailResponse(
|
|||
String userPhoto,
|
||||
String groupedBy,
|
||||
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 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()
|
||||
.map(p -> new OrgProjectStatsResponse(p.projectId(), p.projectName(), p.totalMinutes()))
|
||||
.toList(),
|
||||
output.categoryStats().stream()
|
||||
.map(c -> new OrgCategoryStatsResponse(c.categoryId(), c.categoryName(), c.color(), c.icon(), c.totalMinutes()))
|
||||
.toList(),
|
||||
output.topTasks().stream()
|
||||
.map(t -> new OrgTopTaskResponse(t.taskId(), t.title(), t.projectName(), t.totalMinutes()))
|
||||
.toList(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import java.util.List;
|
|||
|
||||
public record OrgDashboardResponse(
|
||||
List<OrgProjectStatsResponse> projectStats,
|
||||
List<OrgCategoryStatsResponse> categoryStats,
|
||||
List<OrgTopTaskResponse> topTasks,
|
||||
List<OrgMemberStatsResponse> memberStats
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ public record CreateTaskRequest(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
List<TaskHistoryItemResponse> history) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ public record CreateTaskResponse(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
ProjectResponse project,
|
||||
CategoryResponse category,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemResponse> history) {
|
||||
|
||||
public record ProjectResponse(Long id, String name) {}
|
||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||
}
|
||||
|
||||
record TaskHistoryItemResponse(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
|
|
@ -12,5 +14,8 @@ public class ListMyTasksRequest {
|
|||
public Long projectId;
|
||||
|
||||
@QueryParam("status")
|
||||
public TaskStatus status;
|
||||
public List<TaskStatus> statuses;
|
||||
|
||||
@QueryParam("categoryId")
|
||||
public List<Long> categoryIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
|
|||
public class TaskControllerMapper {
|
||||
|
||||
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
|
||||
return new ListMyTasksInput(email, request.text, request.projectId, request.status);
|
||||
return new ListMyTasksInput(email, request.text, request.projectId, request.statuses, request.categoryIds);
|
||||
}
|
||||
|
||||
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
|
||||
|
|
@ -21,6 +21,7 @@ public class TaskControllerMapper {
|
|||
request.description(),
|
||||
request.status(),
|
||||
request.projectId(),
|
||||
request.categoryId(),
|
||||
userEmail,
|
||||
Objects.nonNull(request.history()) ? request.history().stream()
|
||||
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
|
||||
|
|
@ -34,9 +35,19 @@ public class TaskControllerMapper {
|
|||
output.title(),
|
||||
output.description(),
|
||||
output.status(),
|
||||
Objects.nonNull(output.project()) ? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name()) : null,
|
||||
Objects.nonNull(output.project())
|
||||
? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name())
|
||||
: null,
|
||||
Objects.nonNull(output.category())
|
||||
? new CreateTaskResponse.CategoryResponse(
|
||||
output.category().id(),
|
||||
output.category().name(),
|
||||
output.category().color(),
|
||||
output.category().icon())
|
||||
: null,
|
||||
output.finishedAt(),
|
||||
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
||||
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
|
||||
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +66,7 @@ public class TaskControllerMapper {
|
|||
request.description(),
|
||||
request.status(),
|
||||
request.projectId(),
|
||||
request.categoryId(),
|
||||
userEmail,
|
||||
Objects.nonNull(request.history()) ? request.history().stream()
|
||||
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
Long totalMinutesWorked,
|
||||
List<DashboardProjectStats> projectStats,
|
||||
List<DashboardCategoryStats> categoryStats,
|
||||
List<CreateTaskOutput> yesterdayTasks,
|
||||
List<CreateTaskOutput> finishedTasksInPeriod
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
|
|
@ -43,6 +44,7 @@ public class DashboardUseCase {
|
|||
|
||||
long totalMinutes = 0;
|
||||
Map<Long, DashboardProjectStats> projectStatsMap = new HashMap<>();
|
||||
Map<Long, DashboardCategoryStats> categoryStatsMap = new HashMap<>();
|
||||
|
||||
for (Task task : workedTasks) {
|
||||
long taskMinutesInPeriod = task.getHistory().stream()
|
||||
|
|
@ -63,11 +65,21 @@ public class DashboardUseCase {
|
|||
stats.totalMinutes() + taskMinutesInPeriod
|
||||
));
|
||||
}
|
||||
|
||||
if (Objects.nonNull(task.getCategory())) {
|
||||
Category cat = task.getCategory();
|
||||
DashboardCategoryStats existing = categoryStatsMap.getOrDefault(cat.getId(),
|
||||
new DashboardCategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
|
||||
categoryStatsMap.put(cat.getId(), new DashboardCategoryStats(
|
||||
cat.getId(), existing.categoryName(), existing.color(), existing.icon(),
|
||||
existing.totalMinutes() + taskMinutesInPeriod));
|
||||
}
|
||||
}
|
||||
|
||||
DashboardOutput output = new DashboardOutput(
|
||||
totalMinutes,
|
||||
new ArrayList<>(projectStatsMap.values()),
|
||||
new ArrayList<>(categoryStatsMap.values()),
|
||||
yesterdayTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(),
|
||||
finishedTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
List<OrgProjectStats> projectStats,
|
||||
List<OrgCategoryStats> categoryStats,
|
||||
List<OrgTopTask> topTasks,
|
||||
List<OrgMemberStats> memberStats
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganization;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
|
|
@ -54,6 +55,7 @@ public class OrgDashboardUseCase {
|
|||
);
|
||||
|
||||
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
|
||||
Map<Long, OrgCategoryStats> categoryStatsMap = new HashMap<>();
|
||||
Map<String, Long> memberMinutesMap = new HashMap<>();
|
||||
|
||||
for (Task task : workedTasks) {
|
||||
|
|
@ -66,6 +68,15 @@ public class OrgDashboardUseCase {
|
|||
projectStatsMap.put(projectId, new OrgProjectStats(
|
||||
projectId, existing.projectName(), existing.totalMinutes() + minutes));
|
||||
|
||||
if (Objects.nonNull(task.getCategory())) {
|
||||
Category cat = task.getCategory();
|
||||
OrgCategoryStats existingCat = categoryStatsMap.getOrDefault(cat.getId(),
|
||||
new OrgCategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
|
||||
categoryStatsMap.put(cat.getId(), new OrgCategoryStats(
|
||||
cat.getId(), existingCat.categoryName(), existingCat.color(), existingCat.icon(),
|
||||
existingCat.totalMinutes() + minutes));
|
||||
}
|
||||
|
||||
if (Objects.nonNull(task.getUser())) {
|
||||
memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum);
|
||||
}
|
||||
|
|
@ -104,6 +115,7 @@ public class OrgDashboardUseCase {
|
|||
|
||||
OrgDashboardOutput output = new OrgDashboardOutput(
|
||||
new ArrayList<>(projectStatsMap.values()),
|
||||
new ArrayList<>(categoryStatsMap.values()),
|
||||
topTasks,
|
||||
memberStats
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ public class MemberDetailMapper {
|
|||
public static MemberDetailOutput toOutput(UserOrganization member,
|
||||
String groupedBy,
|
||||
List<MemberDetailOutput.PeriodStats> periodStats,
|
||||
List<MemberDetailOutput.ProjectStats> projectStats) {
|
||||
List<MemberDetailOutput.ProjectStats> projectStats,
|
||||
List<MemberDetailOutput.CategoryStats> categoryStats) {
|
||||
return new MemberDetailOutput(
|
||||
member.getUser().getId(),
|
||||
member.getUser().getName(),
|
||||
|
|
@ -17,7 +18,8 @@ public class MemberDetailMapper {
|
|||
member.getUser().getPhoto(),
|
||||
groupedBy,
|
||||
periodStats,
|
||||
projectStats
|
||||
projectStats,
|
||||
categoryStats
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ public record MemberDetailOutput(
|
|||
String userPhoto,
|
||||
String groupedBy,
|
||||
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 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;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.organization.UserOrganization;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
|
|
@ -57,6 +58,7 @@ public class MemberDetailUseCase {
|
|||
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
|
||||
|
||||
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
|
||||
Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>();
|
||||
Map<LocalDate, Long> minutesPerDay = new HashMap<>();
|
||||
Map<YearMonth, Long> minutesPerMonth = new HashMap<>();
|
||||
|
||||
|
|
@ -81,6 +83,15 @@ public class MemberDetailUseCase {
|
|||
new MemberDetailOutput.ProjectStats(projectId, projectName, 0L));
|
||||
projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats(
|
||||
projectId, existing.projectName(), existing.totalMinutes() + minutes));
|
||||
|
||||
if (Objects.nonNull(task.getCategory())) {
|
||||
Category cat = task.getCategory();
|
||||
MemberDetailOutput.CategoryStats existingCat = categoryStatsMap.getOrDefault(cat.getId(),
|
||||
new MemberDetailOutput.CategoryStats(cat.getId(), cat.getName(), cat.getColor(), cat.getIcon(), 0L));
|
||||
categoryStatsMap.put(cat.getId(), new MemberDetailOutput.CategoryStats(
|
||||
cat.getId(), existingCat.categoryName(), existingCat.color(), existingCat.icon(),
|
||||
existingCat.totalMinutes() + minutes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +134,8 @@ public class MemberDetailUseCase {
|
|||
}
|
||||
|
||||
MemberDetailOutput output = MemberDetailMapper.toOutput(member, groupByDay ? "DAY" : "MONTH", periodStats,
|
||||
new ArrayList<>(projectStatsMap.values()));
|
||||
new ArrayList<>(projectStatsMap.values()),
|
||||
new ArrayList<>(categoryStatsMap.values()));
|
||||
LOGGER.infof("Member detail calculated for member %d in organization %d with %d worked tasks and %d finished tasks",
|
||||
input.memberId(), input.organizationId(), workedTasks.size(), finishedTasks.size());
|
||||
return output;
|
||||
|
|
|
|||
|
|
@ -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> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
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> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public record CreateTaskInput(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
String userEmail,
|
||||
List<TaskHistoryItemInput> history) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,16 @@ public class CreateTaskMapper {
|
|||
task.getTitle(),
|
||||
task.getDescription(),
|
||||
task.getStatus(),
|
||||
Objects.nonNull(task.getProject()) ? new CreateTaskOutput.ProjectOutput(task.getProject().getId(), task.getProject().getName()) : null,
|
||||
Objects.nonNull(task.getProject())
|
||||
? new CreateTaskOutput.ProjectOutput(task.getProject().getId(), task.getProject().getName())
|
||||
: null,
|
||||
Objects.nonNull(task.getCategory())
|
||||
? new CreateTaskOutput.CategoryOutput(
|
||||
task.getCategory().getId(),
|
||||
task.getCategory().getName(),
|
||||
task.getCategory().getColor(),
|
||||
task.getCategory().getIcon())
|
||||
: null,
|
||||
task.getFinishedAt(),
|
||||
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ public record CreateTaskOutput(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
ProjectOutput project,
|
||||
CategoryOutput category,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemOutput> history) {
|
||||
|
||||
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.Objects;
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.Project;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
|
@ -14,11 +16,14 @@ public class CreateTaskUseCase {
|
|||
private static final Logger LOGGER = Logger.getLogger(CreateTaskUseCase.class);
|
||||
private final TaskRepository taskRepository;
|
||||
private final ProjectRepository projectRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) {
|
||||
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +43,15 @@ public class CreateTaskUseCase {
|
|||
task.setProject(project);
|
||||
}
|
||||
|
||||
if (Objects.nonNull(input.categoryId())) {
|
||||
Category category = categoryRepository.findCategoryById(input.categoryId())
|
||||
.orElseThrow(() -> {
|
||||
LOGGER.warnf("Task creation failed because category %d was not found", input.categoryId());
|
||||
return new IllegalArgumentException("Categoria não encontrada com ID: " + input.categoryId());
|
||||
});
|
||||
task.setCategory(category);
|
||||
}
|
||||
|
||||
task.setUser(userRepository.findByEmail(input.userEmail())
|
||||
.orElseThrow(() -> {
|
||||
LOGGER.warnf("Task creation failed because user %s was not found", input.userEmail());
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package net.beetechgroup.beetask.usecase.task.listall;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
|
||||
public record ListMyTasksInput(String email, String text, Long projectId, TaskStatus status) {}
|
||||
public record ListMyTasksInput(String email, String text, Long projectId,
|
||||
List<TaskStatus> statuses, List<Long> categoryIds) {}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ public class ListMyTasksUseCase {
|
|||
}
|
||||
|
||||
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();
|
||||
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email());
|
||||
return tasks;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public record UpdateTaskInput(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
Long projectId,
|
||||
Long categoryId,
|
||||
String userEmail,
|
||||
List<TaskHistoryItemInput> history) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package net.beetechgroup.beetask.usecase.task.update;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.beetechgroup.beetask.entities.Category;
|
||||
import net.beetechgroup.beetask.entities.Project;
|
||||
import net.beetechgroup.beetask.entities.task.Task;
|
||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
|
@ -16,11 +18,14 @@ public class UpdateTaskUseCase {
|
|||
private static final Logger LOGGER = Logger.getLogger(UpdateTaskUseCase.class);
|
||||
private final TaskRepository taskRepository;
|
||||
private final ProjectRepository projectRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, UserRepository userRepository) {
|
||||
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||
CategoryRepository categoryRepository, UserRepository userRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.projectRepository = projectRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +56,17 @@ public class UpdateTaskUseCase {
|
|||
task.setProject(null);
|
||||
}
|
||||
|
||||
if (Objects.nonNull(input.categoryId())) {
|
||||
Category category = categoryRepository.findCategoryById(input.categoryId())
|
||||
.orElseThrow(() -> {
|
||||
LOGGER.warnf("Task update failed because category %d was not found", input.categoryId());
|
||||
return new IllegalArgumentException("Categoria não encontrada com ID: " + input.categoryId());
|
||||
});
|
||||
task.setCategory(category);
|
||||
} else {
|
||||
task.setCategory(null);
|
||||
}
|
||||
|
||||
if (Objects.nonNull(input.history())) {
|
||||
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
|
||||
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,
|
||||
ChevronDown,
|
||||
FolderKanban,
|
||||
Tag,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Search,
|
||||
|
|
@ -267,6 +268,13 @@ export function AppShell() {
|
|||
</NavLink>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink to="/categories" className={navLinkClassName}>
|
||||
<Tag size={18} />
|
||||
Categorias
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Equipe Dropdown */}
|
||||
<div className="relative">
|
||||
<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 PeriodStats,
|
||||
type MemberProjectStats,
|
||||
type MemberCategoryStats,
|
||||
} from "../services/orgDashboardService";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
|
||||
const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"];
|
||||
const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"];
|
||||
|
|
@ -155,6 +157,69 @@ function BarChart({
|
|||
);
|
||||
}
|
||||
|
||||
function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) {
|
||||
const [tooltip, setTooltip] = useState<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[] }) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
|
@ -374,6 +439,43 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
|
|||
</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 */}
|
||||
{data.projectStats.length > 0 && (
|
||||
<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 { cn } from '../lib/utils'
|
||||
import type { TaskStatus } from '../types/task'
|
||||
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
|
||||
import type { Project } from '../services/projectService'
|
||||
import type { Category } from '../types/category'
|
||||
import { MultiSelectChips } from './MultiSelectChips'
|
||||
import { CategoryIcon } from './CategoryIcon'
|
||||
|
||||
export interface TaskFilters {
|
||||
searchQuery: string
|
||||
statusFilter: TaskStatus | 'ALL'
|
||||
statuses: TaskStatus[]
|
||||
categoryIds: number[]
|
||||
projectId: number | 'ALL'
|
||||
}
|
||||
|
||||
export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
||||
searchQuery: '',
|
||||
statusFilter: 'ALL',
|
||||
statuses: [],
|
||||
categoryIds: [],
|
||||
projectId: 'ALL',
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +24,7 @@ interface TaskFilterBarProps {
|
|||
filters: TaskFilters
|
||||
onFiltersChange: (filters: TaskFilters) => void
|
||||
projects: Project[]
|
||||
categories: Category[]
|
||||
isLoading?: boolean
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
|
@ -28,6 +33,7 @@ export function TaskFilterBar({
|
|||
filters,
|
||||
onFiltersChange,
|
||||
projects,
|
||||
categories,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
}: TaskFilterBarProps) {
|
||||
|
|
@ -35,66 +41,77 @@ export function TaskFilterBar({
|
|||
onFiltersChange({ ...filters, ...partial })
|
||||
|
||||
return (
|
||||
<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="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||
size={18}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por título ou descrição..."
|
||||
value={filters.searchQuery}
|
||||
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 className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||
size={18}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por título ou descrição..."
|
||||
value={filters.searchQuery}
|
||||
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>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<select
|
||||
value={filters.projectId}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
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"
|
||||
>
|
||||
<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',
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<select
|
||||
value={filters.projectId}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<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">Status</span>
|
||||
<MultiSelectChips
|
||||
options={TASK_STATUS_OPTIONS.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))}
|
||||
selected={filters.statuses}
|
||||
onChange={(statuses) => update({ statuses })}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
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 { useEffect } from "react";
|
||||
import { projectService, type Project } from "../services/projectService";
|
||||
import { categoryService } from "../services/categoryService";
|
||||
import type { Category } from "../types/category";
|
||||
import { CategoryIcon } from "../components/CategoryIcon";
|
||||
|
||||
type FormState = Omit<CreateTaskPayload, 'projectId'> & {
|
||||
type FormState = Omit<CreateTaskPayload, 'projectId' | 'categoryId'> & {
|
||||
projectId: string;
|
||||
categoryId: string;
|
||||
history: TaskHistoryItem[];
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +36,7 @@ const initialFormState: FormState = {
|
|||
description: "",
|
||||
status: "NOT_STARTED",
|
||||
projectId: "",
|
||||
categoryId: "",
|
||||
history: [],
|
||||
};
|
||||
|
||||
|
|
@ -41,6 +46,7 @@ export function CreateTaskPage() {
|
|||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const { activeOrg } = useAuth();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -56,6 +62,7 @@ export function CreateTaskPage() {
|
|||
description: data.description || "",
|
||||
status: data.status,
|
||||
projectId: data.project?.id?.toString() || "",
|
||||
categoryId: data.category?.id?.toString() || "",
|
||||
history: data.history || [],
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -77,7 +84,17 @@ export function CreateTaskPage() {
|
|||
console.error("Erro ao carregar projetos:", error);
|
||||
}
|
||||
}
|
||||
async function loadCategories() {
|
||||
if (!activeOrg) return;
|
||||
try {
|
||||
const data = await categoryService.listByOrganization(activeOrg.id);
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar categorias:", error);
|
||||
}
|
||||
}
|
||||
loadProjects();
|
||||
loadCategories();
|
||||
}, [activeOrg]);
|
||||
|
||||
function updateField(
|
||||
|
|
@ -103,6 +120,7 @@ export function CreateTaskPage() {
|
|||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
projectId: form.projectId ? Number(form.projectId) : null,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : null,
|
||||
history: form.history,
|
||||
};
|
||||
|
||||
|
|
@ -233,8 +251,8 @@ export function CreateTaskPage() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{/* Project & Status Row */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Project, Category & Status Row */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<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">
|
||||
<Layout size={16} />
|
||||
|
|
@ -255,6 +273,37 @@ export function CreateTaskPage() {
|
|||
</select>
|
||||
</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">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
|
||||
<Layout size={16} />
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
type DashboardData,
|
||||
} from "../services/dashboardService";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PieChart } from "../components/PieChart";
|
||||
import { CategoryIcon } from "../components/CategoryIcon";
|
||||
import { format, subDays, startOfMonth, endOfMonth } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
|
||||
|
|
@ -227,6 +229,65 @@ export const DashboardPage: React.FC = () => {
|
|||
</motion.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">
|
||||
{/* Project Breakdown */}
|
||||
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import {
|
|||
type OrgProjectStats,
|
||||
} from "../services/orgDashboardService";
|
||||
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 = [
|
||||
"#7C3AED",
|
||||
|
|
@ -372,6 +375,67 @@ export const OrgDashboardPage: React.FC = () => {
|
|||
</section>
|
||||
</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 */}
|
||||
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||
<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 { projectService } 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 type { TaskResponse, TaskStatus } from '../types/task'
|
||||
import { TaskTimer } from '../components/TaskTimer'
|
||||
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
|
||||
import type { TaskFilters } from '../components/TaskFilterBar'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
|
||||
|
|
@ -33,6 +36,7 @@ export function TaskBoardPage() {
|
|||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||
const navigate = useNavigate()
|
||||
const { activeOrg } = useAuth()
|
||||
|
|
@ -44,7 +48,8 @@ export function TaskBoardPage() {
|
|||
const response = await taskService.getMyTasks({
|
||||
text: currentFilters.searchQuery || 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)
|
||||
} catch (error) {
|
||||
|
|
@ -58,6 +63,7 @@ export function TaskBoardPage() {
|
|||
void loadTasks(filters)
|
||||
if (activeOrg) {
|
||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -138,6 +144,7 @@ export function TaskBoardPage() {
|
|||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
isLoading={isLoading}
|
||||
onRefresh={() => void loadTasks(filters)}
|
||||
/>
|
||||
|
|
@ -207,9 +214,12 @@ export function TaskBoardPage() {
|
|||
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
|
||||
<AlertCircle size={12} className="text-brand" />
|
||||
{task.project?.name || 'Geral'}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
|
||||
<AlertCircle size={12} className="text-brand" />
|
||||
{task.project?.name || 'Geral'}
|
||||
</div>
|
||||
<CategoryBadge category={task.category} />
|
||||
</div>
|
||||
<span className="font-mono text-[10px] font-bold text-text-muted/50">
|
||||
#{task.id}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||
import { taskService } from '../services/taskService'
|
||||
import { projectService } 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 type { TaskResponse } from '../types/task'
|
||||
import { TaskTimer } from '../components/TaskTimer'
|
||||
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
|
||||
import type { TaskFilters } from '../components/TaskFilterBar'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function getStatusConfig(status: TaskResponse['status']) {
|
||||
|
|
@ -57,6 +60,7 @@ export function TaskListPage() {
|
|||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||
const navigate = useNavigate()
|
||||
const { activeOrg } = useAuth()
|
||||
|
|
@ -68,7 +72,8 @@ export function TaskListPage() {
|
|||
const response = await taskService.getMyTasks({
|
||||
text: currentFilters.searchQuery || 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)
|
||||
} catch (error) {
|
||||
|
|
@ -82,6 +87,7 @@ export function TaskListPage() {
|
|||
void loadTasks(filters)
|
||||
if (activeOrg) {
|
||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -132,6 +138,7 @@ export function TaskListPage() {
|
|||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
isLoading={isLoading}
|
||||
onRefresh={() => void loadTasks(filters)}
|
||||
/>
|
||||
|
|
@ -184,6 +191,12 @@ export function TaskListPage() {
|
|||
<Tag size={14} className="text-brand" />
|
||||
{task.project?.name || 'Geral'}
|
||||
</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="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||
<Calendar size={14} className="text-accent" />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { JoinOrganizationPage } from "./pages/JoinOrganizationPage";
|
|||
import { OrganizationAdminPage } from "./pages/OrganizationAdminPage";
|
||||
import { MyRequestsPage } from "./pages/MyRequestsPage";
|
||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||
import { CategoriesPage } from "./pages/CategoriesPage";
|
||||
import { OrganizationsPage } from "./pages/OrganizationsPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
||||
|
|
@ -73,6 +74,14 @@ export const router = createBrowserRouter([
|
|||
</RoleGate>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "categories",
|
||||
element: (
|
||||
<RoleGate allowedRoles={["OWNER", "ADMIN"]} message="Apenas administradores e proprietários podem gerenciar categorias.">
|
||||
<CategoriesPage />
|
||||
</RoleGate>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "organizations",
|
||||
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
|
||||
}
|
||||
|
||||
export interface CategoryStats {
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
color: string
|
||||
icon: string
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
totalMinutesWorked: number
|
||||
projectStats: ProjectStats[]
|
||||
categoryStats: CategoryStats[]
|
||||
yesterdayTasks: TaskResponse[]
|
||||
finishedTasksInPeriod: TaskResponse[]
|
||||
}
|
||||
|
|
@ -19,7 +28,7 @@ export const dashboardService = {
|
|||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('startDate', startDate)
|
||||
if (endDate) params.append('endDate', endDate)
|
||||
|
||||
|
||||
return apiFetch<DashboardData>(`/dashboard?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ export interface OrgProjectStats {
|
|||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface OrgCategoryStats {
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
color: string
|
||||
icon: string
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface OrgTopTask {
|
||||
taskId: number
|
||||
title: string
|
||||
|
|
@ -23,6 +31,7 @@ export interface OrgMemberStats {
|
|||
|
||||
export interface OrgDashboardData {
|
||||
projectStats: OrgProjectStats[]
|
||||
categoryStats: OrgCategoryStats[]
|
||||
topTasks: OrgTopTask[]
|
||||
memberStats: OrgMemberStats[]
|
||||
}
|
||||
|
|
@ -41,6 +50,14 @@ export interface MemberProjectStats {
|
|||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface MemberCategoryStats {
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
color: string
|
||||
icon: string
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface MemberDetailData {
|
||||
userId: number
|
||||
userName: string
|
||||
|
|
@ -49,6 +66,7 @@ export interface MemberDetailData {
|
|||
groupedBy: 'DAY' | 'MONTH'
|
||||
periodStats: PeriodStats[]
|
||||
projectStats: MemberProjectStats[]
|
||||
categoryStats: MemberCategoryStats[]
|
||||
}
|
||||
|
||||
export const orgDashboardService = {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,35 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
||||
|
||||
export interface GetMyTasksFilters {
|
||||
text?: string
|
||||
projectId?: number
|
||||
statuses?: TaskStatus[]
|
||||
categoryIds?: number[]
|
||||
}
|
||||
|
||||
export const taskService = {
|
||||
async getTasks(): Promise<TaskResponse[]> {
|
||||
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()
|
||||
if (filters?.text) params.set('text', filters.text)
|
||||
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()
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: task,
|
||||
|
|
@ -45,7 +59,7 @@ export const taskService = {
|
|||
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}`, {
|
||||
method: 'PUT',
|
||||
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
|
||||
status: TaskStatus
|
||||
projectId: number | null
|
||||
categoryId: number | null
|
||||
}
|
||||
|
||||
export type TaskHistoryItem = {
|
||||
|
|
@ -32,12 +33,20 @@ export type TaskProject = {
|
|||
name: string
|
||||
}
|
||||
|
||||
export type TaskCategory = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type TaskResponse = {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
project: TaskProject | null
|
||||
category: TaskCategory | null
|
||||
finishedAt?: string
|
||||
history?: TaskHistoryItem[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue