dashboard
This commit is contained in:
parent
14b9b6985a
commit
1688cf092e
30 changed files with 696 additions and 31 deletions
|
|
@ -5,6 +5,7 @@ import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.Project;
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
|
||||||
public class Task {
|
public class Task {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
@ -12,6 +13,7 @@ public class Task {
|
||||||
private String description;
|
private String description;
|
||||||
private TaskStatus status;
|
private TaskStatus status;
|
||||||
private Project project;
|
private Project project;
|
||||||
|
private LocalDateTime finishedAt;
|
||||||
private List<TaskHistoryItem> history = new ArrayList<>();
|
private List<TaskHistoryItem> history = new ArrayList<>();
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
|
|
@ -45,19 +47,26 @@ public class Task {
|
||||||
public void setStatus(TaskStatus status) {
|
public void setStatus(TaskStatus status) {
|
||||||
if (this.status == status) return;
|
if (this.status == status) return;
|
||||||
|
|
||||||
if (status == TaskStatus.IN_PROGRESS) {
|
// Note: Automatic start/stop removed from setStatus to avoid
|
||||||
if (!isRunning()) {
|
// compilation errors and ensure user attribution via StartTaskUseCase.
|
||||||
start();
|
|
||||||
}
|
if (status == TaskStatus.COMPLETED) {
|
||||||
} else if (this.status == TaskStatus.IN_PROGRESS || isRunning()) {
|
this.finishedAt = LocalDateTime.now();
|
||||||
if (isRunning()) {
|
} else {
|
||||||
stop();
|
this.finishedAt = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getFinishedAt() {
|
||||||
|
return finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFinishedAt(LocalDateTime finishedAt) {
|
||||||
|
this.finishedAt = finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Project getProject() {
|
public Project getProject() {
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +94,7 @@ public class Task {
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start(User user) {
|
||||||
TaskHistoryItem current = getCurrentRunningItem();
|
TaskHistoryItem current = getCurrentRunningItem();
|
||||||
|
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
|
|
@ -95,6 +104,7 @@ public class Task {
|
||||||
this.status = TaskStatus.IN_PROGRESS;
|
this.status = TaskStatus.IN_PROGRESS;
|
||||||
TaskHistoryItem item = new TaskHistoryItem();
|
TaskHistoryItem item = new TaskHistoryItem();
|
||||||
item.setStartAt(LocalDateTime.now());
|
item.setStartAt(LocalDateTime.now());
|
||||||
|
item.setUser(user);
|
||||||
this.history.add(item);
|
this.history.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package net.beetechgroup.beetask.entities.task;
|
package net.beetechgroup.beetask.entities.task;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
|
||||||
public class TaskHistoryItem {
|
public class TaskHistoryItem {
|
||||||
private LocalDateTime startAt;
|
private LocalDateTime startAt;
|
||||||
private LocalDateTime endAt;
|
private LocalDateTime endAt;
|
||||||
|
private User user;
|
||||||
|
|
||||||
public void setEndAt(LocalDateTime endAt) {
|
public void setEndAt(LocalDateTime endAt) {
|
||||||
this.endAt = endAt;
|
this.endAt = endAt;
|
||||||
|
|
@ -25,4 +27,12 @@ public class TaskHistoryItem {
|
||||||
public boolean isRunning() {
|
public boolean isRunning() {
|
||||||
return endAt == null;
|
return endAt == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.config;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.inject.Produces;
|
||||||
|
import net.beetechgroup.beetask.usecase.dashboard.DashboardUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DashboardUseCaseConfig {
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public DashboardUseCase dashboardUseCase(TaskRepository taskRepository) {
|
||||||
|
return new DashboardUseCase(taskRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
|
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
||||||
|
|
@ -19,8 +20,8 @@ public class TaskUseCaseConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository) {
|
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||||
return new StartTaskUseCase(taskRepository);
|
return new StartTaskUseCase(taskRepository, userRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
import jakarta.persistence.CollectionTable;
|
import jakarta.persistence.CollectionTable;
|
||||||
import jakarta.persistence.ElementCollection;
|
import jakarta.persistence.ElementCollection;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
|
@ -10,6 +11,7 @@ import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
|
||||||
|
|
@ -22,12 +24,12 @@ public class TaskEntity {
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
private TaskStatus status;
|
private TaskStatus status;
|
||||||
|
private java.time.LocalDateTime finishedAt;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "project_id")
|
@JoinColumn(name = "project_id")
|
||||||
private ProjectEntity project;
|
private ProjectEntity project;
|
||||||
@ElementCollection
|
@OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
@CollectionTable(name = "task_history", joinColumns = @JoinColumn(name = "task_id"))
|
|
||||||
private List<TaskHistoryItemEntity> history;
|
private List<TaskHistoryItemEntity> history;
|
||||||
|
|
||||||
public void setHistory(List<TaskHistoryItemEntity> history) {
|
public void setHistory(List<TaskHistoryItemEntity> history) {
|
||||||
|
|
@ -70,6 +72,14 @@ public class TaskEntity {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getFinishedAt() {
|
||||||
|
return finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFinishedAt(java.time.LocalDateTime finishedAt) {
|
||||||
|
this.finishedAt = finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public ProjectEntity getProject() {
|
public ProjectEntity getProject() {
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,40 @@ package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import jakarta.persistence.Embeddable;
|
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;
|
||||||
|
|
||||||
@Embeddable
|
@Entity
|
||||||
|
@Table(name = "task_history")
|
||||||
public class TaskHistoryItemEntity {
|
public class TaskHistoryItemEntity {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
private LocalDateTime startAt;
|
private LocalDateTime startAt;
|
||||||
private LocalDateTime endAt;
|
private LocalDateTime endAt;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "user_id")
|
||||||
|
private UserEntity user;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "task_id")
|
||||||
|
private TaskEntity task;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getStartAt() {
|
public LocalDateTime getStartAt() {
|
||||||
return startAt;
|
return startAt;
|
||||||
}
|
}
|
||||||
|
|
@ -24,4 +51,20 @@ public class TaskHistoryItemEntity {
|
||||||
public void setEndAt(LocalDateTime endAt) {
|
public void setEndAt(LocalDateTime endAt) {
|
||||||
this.endAt = endAt;
|
this.endAt = endAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UserEntity getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(UserEntity user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TaskEntity getTask() {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTask(TaskEntity task) {
|
||||||
|
this.task = task;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity;
|
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity;
|
||||||
|
|
||||||
public class TaskEntityMapper {
|
public class TaskEntityMapper {
|
||||||
|
|
||||||
|
|
@ -13,8 +14,13 @@ public class TaskEntityMapper {
|
||||||
entity.setTitle(task.getTitle());
|
entity.setTitle(task.getTitle());
|
||||||
entity.setDescription(task.getDescription());
|
entity.setDescription(task.getDescription());
|
||||||
entity.setStatus(task.getStatus());
|
entity.setStatus(task.getStatus());
|
||||||
|
entity.setFinishedAt(task.getFinishedAt());
|
||||||
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
||||||
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(TaskHistoryItemMapper::toEntity).toList()));
|
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
|
||||||
|
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
|
||||||
|
he.setTask(entity);
|
||||||
|
return he;
|
||||||
|
}).toList()));
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +32,7 @@ public class TaskEntityMapper {
|
||||||
task.setTitle(entity.getTitle());
|
task.setTitle(entity.getTitle());
|
||||||
task.setDescription(entity.getDescription());
|
task.setDescription(entity.getDescription());
|
||||||
task.setStatus(entity.getStatus());
|
task.setStatus(entity.getStatus());
|
||||||
|
task.setFinishedAt(entity.getFinishedAt());
|
||||||
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||||
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
|
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ public class TaskHistoryItemMapper {
|
||||||
TaskHistoryItemEntity entity = new TaskHistoryItemEntity();
|
TaskHistoryItemEntity entity = new TaskHistoryItemEntity();
|
||||||
entity.setStartAt(item.getStartAt());
|
entity.setStartAt(item.getStartAt());
|
||||||
entity.setEndAt(item.getEndAt());
|
entity.setEndAt(item.getEndAt());
|
||||||
|
if (item.getUser() != null) {
|
||||||
|
entity.setUser(UserEntityMapper.toEntity(item.getUser()));
|
||||||
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,6 +20,9 @@ public class TaskHistoryItemMapper {
|
||||||
TaskHistoryItem item = new TaskHistoryItem();
|
TaskHistoryItem item = new TaskHistoryItem();
|
||||||
item.setStartAt(entity.getStartAt());
|
item.setStartAt(entity.getStartAt());
|
||||||
item.setEndAt(entity.getEndAt());
|
item.setEndAt(entity.getEndAt());
|
||||||
|
if (entity.getUser() != null) {
|
||||||
|
item.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||||
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.frameworks.persistence.repository;
|
package net.beetechgroup.beetask.frameworks.persistence.repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
@ -37,4 +38,16 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
public List<Task> findAllTasks() {
|
public List<Task> findAllTasks() {
|
||||||
return findAll().list().stream().map(TaskEntityMapper::toDomain).toList();
|
return findAll().list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
||||||
|
return find("select distinct t from TaskEntity t join t.history h where h.user.email = ?1 and h.startAt >= ?2 and h.startAt <= ?3",
|
||||||
|
email, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
||||||
|
return find("select distinct t from TaskEntity t join t.history h where h.user.email = ?1 and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||||
|
email, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.dashboard;
|
||||||
|
|
||||||
|
import io.quarkus.security.Authenticated;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import net.beetechgroup.beetask.usecase.dashboard.DashboardInput;
|
||||||
|
import net.beetechgroup.beetask.usecase.dashboard.DashboardOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.dashboard.DashboardUseCase;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@Path("/dashboard")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Authenticated
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DashboardUseCase dashboardUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public DashboardOutput getDashboard(
|
||||||
|
@QueryParam("startDate") String startDateStr,
|
||||||
|
@QueryParam("endDate") String endDateStr) {
|
||||||
|
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
|
||||||
|
LocalDateTime start = (startDateStr != null)
|
||||||
|
? LocalDateTime.parse(startDateStr, DateTimeFormatter.ISO_DATE_TIME)
|
||||||
|
: LocalDateTime.now().minusMonths(1);
|
||||||
|
|
||||||
|
LocalDateTime end = (endDateStr != null)
|
||||||
|
? LocalDateTime.parse(endDateStr, DateTimeFormatter.ISO_DATE_TIME)
|
||||||
|
: LocalDateTime.now();
|
||||||
|
|
||||||
|
DashboardInput input = new DashboardInput(email, start, end);
|
||||||
|
return dashboardUseCase.execute(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ public record CreateTaskResponse(
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
TaskStatus status,
|
||||||
String project,
|
String projectName,
|
||||||
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemResponse> history) {
|
List<TaskHistoryItemResponse> history) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -47,6 +49,9 @@ public class TaskController {
|
||||||
@Inject
|
@Inject
|
||||||
UpdateTaskStatusUseCase updateTaskStatusUseCase;
|
UpdateTaskStatusUseCase updateTaskStatusUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Operation(summary = "Create a new task", description = "Creates a new task in the system")
|
@Operation(summary = "Create a new task", description = "Creates a new task in the system")
|
||||||
@APIResponse(responseCode = "201", description = "Task created successfully")
|
@APIResponse(responseCode = "201", description = "Task created successfully")
|
||||||
|
|
@ -61,7 +66,8 @@ public class TaskController {
|
||||||
@Operation(summary = "Start a task", description = "Starts a task in the system")
|
@Operation(summary = "Start a task", description = "Starts a task in the system")
|
||||||
@APIResponse(responseCode = "200", description = "Task started successfully")
|
@APIResponse(responseCode = "200", description = "Task started successfully")
|
||||||
public CreateTaskResponse startTask(@PathParam("id") Long id) {
|
public CreateTaskResponse startTask(@PathParam("id") Long id) {
|
||||||
CreateTaskOutput output = startTaskUseCase.execute(TaskControllerMapper.toStartTaskInput(id));
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
CreateTaskOutput output = startTaskUseCase.execute(new StartTaskInput(id, email));
|
||||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ public class TaskControllerMapper {
|
||||||
output.title(),
|
output.title(),
|
||||||
output.description(),
|
output.description(),
|
||||||
output.status(),
|
output.status(),
|
||||||
output.project(),
|
output.projectName(),
|
||||||
|
output.finishedAt(),
|
||||||
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StartTaskInput toStartTaskInput(Long id) {
|
public static StartTaskInput toStartTaskInput(Long id, String email) {
|
||||||
return new StartTaskInput(id);
|
return new StartTaskInput(id, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StopTaskInput toStopTaskInput(Long id) {
|
public static StopTaskInput toStopTaskInput(Long id) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.dashboard;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record DashboardInput(
|
||||||
|
String userEmail,
|
||||||
|
LocalDateTime startDate,
|
||||||
|
LocalDateTime endDate
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.dashboard;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
||||||
|
public record DashboardOutput(
|
||||||
|
Long totalMinutesWorked,
|
||||||
|
List<DashboardProjectStats> projectStats,
|
||||||
|
List<CreateTaskOutput> yesterdayTasks,
|
||||||
|
List<CreateTaskOutput> finishedTasksInPeriod
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.dashboard;
|
||||||
|
|
||||||
|
public record DashboardProjectStats(
|
||||||
|
Long projectId,
|
||||||
|
String projectName,
|
||||||
|
Long totalMinutes
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.dashboard;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
|
|
||||||
|
public class DashboardUseCase {
|
||||||
|
|
||||||
|
private final TaskRepository taskRepository;
|
||||||
|
|
||||||
|
public DashboardUseCase(TaskRepository taskRepository) {
|
||||||
|
this.taskRepository = taskRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardOutput execute(DashboardInput input) {
|
||||||
|
// 1. Fetch tasks worked by user in period
|
||||||
|
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
||||||
|
input.userEmail(), input.startDate(), input.endDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Fetch tasks finished by user in period
|
||||||
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(
|
||||||
|
input.userEmail(), input.startDate(), input.endDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Yesterday's tasks
|
||||||
|
LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN);
|
||||||
|
LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX);
|
||||||
|
List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
||||||
|
input.userEmail(), startOfYesterday, endOfYesterday
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate total minutes and project stats
|
||||||
|
long totalMinutes = 0;
|
||||||
|
Map<Long, DashboardProjectStats> projectStatsMap = new HashMap<>();
|
||||||
|
|
||||||
|
for (Task task : workedTasks) {
|
||||||
|
long taskMinutesInPeriod = task.getHistory().stream()
|
||||||
|
.filter(h -> h.getUser() != null && h.getUser().getEmail().equals(input.userEmail()))
|
||||||
|
.filter(h -> isWithinPeriod(h, input.startDate(), input.endDate()))
|
||||||
|
.mapToLong(h -> calculateMinutesInRange(h, input.startDate(), input.endDate()))
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
totalMinutes += taskMinutesInPeriod;
|
||||||
|
|
||||||
|
if (task.getProject() != null) {
|
||||||
|
Long projectId = task.getProject().getId();
|
||||||
|
DashboardProjectStats stats = projectStatsMap.getOrDefault(projectId,
|
||||||
|
new DashboardProjectStats(projectId, task.getProject().getName(), 0L));
|
||||||
|
|
||||||
|
projectStatsMap.put(projectId, new DashboardProjectStats(
|
||||||
|
projectId,
|
||||||
|
stats.projectName(),
|
||||||
|
stats.totalMinutes() + taskMinutesInPeriod
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DashboardOutput(
|
||||||
|
totalMinutes,
|
||||||
|
new ArrayList<>(projectStatsMap.values()),
|
||||||
|
yesterdayTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(),
|
||||||
|
finishedTasks.stream().map(CreateTaskMapper::toCreateTaskOutput).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isWithinPeriod(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) {
|
||||||
|
if (item.getStartAt() == null) return false;
|
||||||
|
// If it started after end or ended before start, it's out
|
||||||
|
if (item.getStartAt().isAfter(end)) return false;
|
||||||
|
if (item.getEndAt() != null && item.getEndAt().isBefore(start)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long calculateMinutesInRange(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) {
|
||||||
|
LocalDateTime effectiveStart = item.getStartAt().isBefore(start) ? start : item.getStartAt();
|
||||||
|
LocalDateTime effectiveEnd = item.getEndAt() == null || item.getEndAt().isAfter(end) ? end : item.getEndAt();
|
||||||
|
|
||||||
|
if (effectiveStart.isAfter(effectiveEnd)) return 0;
|
||||||
|
|
||||||
|
return Duration.between(effectiveStart, effectiveEnd).toMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.exceptions;
|
||||||
|
|
||||||
|
public class UserNotFoundException extends RuntimeException {
|
||||||
|
public UserNotFoundException(String email) {
|
||||||
|
super("User not found with email: " + email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.repository;
|
package net.beetechgroup.beetask.usecase.repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
|
||||||
|
|
@ -8,4 +9,6 @@ public interface TaskRepository {
|
||||||
Task saveTask(Task task);
|
Task saveTask(Task task);
|
||||||
Task findTaskById(Long id);
|
Task findTaskById(Long id);
|
||||||
List<Task> findAllTasks();
|
List<Task> findAllTasks();
|
||||||
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ public class CreateTaskMapper {
|
||||||
task.getDescription(),
|
task.getDescription(),
|
||||||
task.getStatus(),
|
task.getStatus(),
|
||||||
task.getProject() != null ? task.getProject().getName() : null,
|
task.getProject() != null ? task.getProject().getName() : null,
|
||||||
|
task.getFinishedAt(),
|
||||||
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
|
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.create;
|
package net.beetechgroup.beetask.usecase.task.create;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
|
||||||
public record CreateTaskOutput(
|
public record CreateTaskOutput(
|
||||||
|
|
@ -9,7 +9,8 @@ public record CreateTaskOutput(
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description,
|
||||||
TaskStatus status,
|
TaskStatus status,
|
||||||
String project,
|
String projectName,
|
||||||
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemOutput> history) {
|
List<TaskHistoryItemOutput> history) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.start;
|
package net.beetechgroup.beetask.usecase.task.start;
|
||||||
|
|
||||||
public record StartTaskInput(Long id) {
|
public record StartTaskInput(Long id, String userEmail) {}
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.start;
|
package net.beetechgroup.beetask.usecase.task.start;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException;
|
||||||
|
|
||||||
public class StartTaskUseCase {
|
public class StartTaskUseCase {
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public StartTaskUseCase(TaskRepository taskRepository) {
|
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(StartTaskInput input) {
|
public CreateTaskOutput execute(StartTaskInput input) {
|
||||||
Task task = taskRepository.findTaskById(input.id());
|
Task task = taskRepository.findTaskById(input.id());
|
||||||
task.start();
|
User user = userRepository.findByEmail(input.userEmail())
|
||||||
|
.orElseThrow(() -> new UserNotFoundException(input.userEmail()));
|
||||||
|
task.start(user);
|
||||||
return CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
return CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Shield,
|
Shield,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NavLink, Outlet, Navigate, Link } from "react-router-dom";
|
import { NavLink, Outlet, Navigate, Link } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
@ -180,6 +181,11 @@ export function AppShell() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 md:flex">
|
<nav className="hidden items-center gap-1 md:flex">
|
||||||
|
<NavLink to="/dashboard" className={navLinkClassName}>
|
||||||
|
<BarChart3 size={18} />
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* Tarefas Dropdown */}
|
{/* Tarefas Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
@ -360,6 +366,16 @@ export function AppShell() {
|
||||||
{/* Footer / Mobile Nav */}
|
{/* Footer / Mobile Nav */}
|
||||||
<footer className="border-t border-border-soft bg-surface py-6 md:hidden">
|
<footer className="border-t border-border-soft bg-surface py-6 md:hidden">
|
||||||
<nav className="flex justify-around px-4">
|
<nav className="flex justify-around px-4">
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(navLinkClassName({ isActive }), "flex-col gap-0.5 py-1")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BarChart3 size={20} />
|
||||||
|
<span className="text-[10px]">Dash</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* Mobile Tarefas Group */}
|
{/* Mobile Tarefas Group */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
310
frontend/src/pages/DashboardPage.tsx
Normal file
310
frontend/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingUp,
|
||||||
|
Layout,
|
||||||
|
FileText,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
ArrowUpRight,
|
||||||
|
History,
|
||||||
|
Briefcase
|
||||||
|
} from "lucide-react";
|
||||||
|
import { dashboardService, type DashboardData } from "../services/dashboardService";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { format, subDays, startOfMonth, endOfMonth } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
|
||||||
|
export const DashboardPage: React.FC = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||||
|
const [endDate, setEndDate] = useState(endOfMonth(new Date()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await dashboardService.getDashboard(
|
||||||
|
startDate.toISOString(),
|
||||||
|
endDate.toISOString()
|
||||||
|
);
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Dashboard error:", err);
|
||||||
|
setError("Falha ao carregar os dados do dashboard.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number) => {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 pb-20">
|
||||||
|
{/* Header & Filters */}
|
||||||
|
<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">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
Visão Geral
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-black tracking-tight text-text-main">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-lg text-text-muted">
|
||||||
|
Acompanhe sua produtividade e tempo gasto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 bg-surface border border-border-soft p-2 rounded-2xl shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-r border-border-soft">
|
||||||
|
<Calendar size={18} className="text-brand" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={format(startDate, 'yyyy-MM-dd')}
|
||||||
|
onChange={(e) => setStartDate(new Date(e.target.value))}
|
||||||
|
className="bg-transparent border-none text-sm font-bold text-text-main focus:ring-0 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={format(endDate, 'yyyy-MM-dd')}
|
||||||
|
onChange={(e) => setEndDate(new Date(e.target.value))}
|
||||||
|
className="bg-transparent border-none text-sm font-bold text-text-main focus:ring-0 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchDashboardData}
|
||||||
|
className="p-2 bg-brand text-white rounded-xl hover:scale-105 transition-transform active:scale-95"
|
||||||
|
>
|
||||||
|
<Filter size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||||
|
<Loader2 className="animate-spin text-brand mb-4" size={48} />
|
||||||
|
<p className="text-text-muted font-bold">Gerando estatísticas...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center p-6 bg-surface rounded-[2.5rem] border border-border-soft">
|
||||||
|
<AlertCircle size={48} className="text-rose-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-text-main mb-2">Ocorreu um erro</h3>
|
||||||
|
<p className="text-text-muted max-w-sm mb-6">{error}</p>
|
||||||
|
<button onClick={fetchDashboardData} className="px-6 py-3 bg-brand text-white rounded-xl font-bold hover:bg-brand-dark transition-all">
|
||||||
|
Tentar novamente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-surface border border-border-soft p-8 rounded-[2.5rem] relative overflow-hidden group shadow-sm hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="w-12 h-12 bg-brand/10 text-brand rounded-xl flex items-center justify-center mb-6">
|
||||||
|
<Clock size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-text-muted uppercase tracking-wider">Tempo Total</div>
|
||||||
|
<div className="mt-2 text-4xl font-black text-text-main">{formatMinutes(data.totalMinutesWorked)}</div>
|
||||||
|
<div className="mt-4 flex items-center gap-1.5 text-[10px] font-black text-brand bg-brand/5 w-fit px-2 py-1 rounded-full uppercase tracking-widest">
|
||||||
|
<TrendingUp size={12} />
|
||||||
|
Foco em alta
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Clock size={120} className="absolute -right-10 -bottom-10 text-brand/5 -rotate-12 transition-transform group-hover:rotate-0" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="bg-surface border border-border-soft p-8 rounded-[2.5rem] relative overflow-hidden group shadow-sm hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="w-12 h-12 bg-accent/10 text-accent rounded-xl flex items-center justify-center mb-6">
|
||||||
|
<CheckCircle2 size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-text-muted uppercase tracking-wider">Tarefas Concluídas</div>
|
||||||
|
<div className="mt-2 text-4xl font-black text-text-main">{data.finishedTasksInPeriod.length}</div>
|
||||||
|
<div className="mt-4 flex items-center gap-1.5 text-[10px] font-black text-accent bg-accent/5 w-fit px-2 py-1 rounded-full uppercase tracking-widest">
|
||||||
|
<CheckCircle2 size={12} />
|
||||||
|
Periodo de Entrega
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 size={120} className="absolute -right-10 -bottom-10 text-accent/5 -rotate-12 transition-transform group-hover:rotate-0" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="bg-accent p-8 rounded-[2.5rem] relative overflow-hidden group shadow-xl shadow-accent/20 transition-all text-white"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="w-12 h-12 bg-white/20 text-white rounded-xl flex items-center justify-center mb-6">
|
||||||
|
<FileText size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-white/70 uppercase tracking-wider">Média por Projeto</div>
|
||||||
|
<div className="mt-2 text-4xl font-black">
|
||||||
|
{data.projectStats.length > 0
|
||||||
|
? formatMinutes(Math.round(data.totalMinutesWorked / data.projectStats.length))
|
||||||
|
: '0h 0m'}
|
||||||
|
</div>
|
||||||
|
<button className="mt-6 inline-flex items-center gap-2 text-xs font-black uppercase tracking-widest bg-white/10 hover:bg-white/20 px-4 py-2 rounded-xl transition-all">
|
||||||
|
<Download size={14} />
|
||||||
|
Exportar Relatório
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TrendingUp size={160} className="absolute -right-10 -bottom-10 text-white/10 -rotate-12 transition-transform group-hover:rotate-0" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Briefcase size={20} className="text-brand" />
|
||||||
|
<h2 className="text-xl font-black text-text-main">Tempo por Projeto</h2>
|
||||||
|
</div>
|
||||||
|
<BarChart3 size={20} className="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{data.projectStats.length > 0 ? data.projectStats.map((stat, idx) => {
|
||||||
|
const percentage = (stat.totalMinutes / data.totalMinutesWorked) * 100;
|
||||||
|
return (
|
||||||
|
<div key={stat.projectId} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-bold text-text-main flex items-center gap-2">
|
||||||
|
<div className={cn("w-2 h-2 rounded-full", idx % 2 === 0 ? "bg-brand" : "bg-accent")} />
|
||||||
|
{stat.projectName}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-black text-text-muted">{formatMinutes(stat.totalMinutes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full bg-surface-muted rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${percentage}%` }}
|
||||||
|
className={cn("h-full rounded-full", idx % 2 === 0 ? "bg-brand" : "bg-accent")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="text-center py-10 opacity-50 font-bold">Nenhum dado por projeto.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Daily Section (Yesterday) */}
|
||||||
|
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm overflow-hidden relative">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<History size={20} className="text-accent" />
|
||||||
|
<h2 className="text-xl font-black text-text-main">Daily (Ontem)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-bold text-text-muted uppercase bg-surface-muted px-2 py-1 rounded-lg">
|
||||||
|
{format(subDays(new Date(), 1), 'dd MMM', { locale: ptBR })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{data.yesterdayTasks.length > 0 ? data.yesterdayTasks.map(task => (
|
||||||
|
<div key={task.id} className="flex items-center justify-between p-4 bg-app-bg rounded-2xl border border-border-soft group hover:border-brand/30 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-brand/10 text-brand rounded-xl flex items-center justify-center shrink-0">
|
||||||
|
<Layout size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-text-main line-clamp-1">{task.title}</div>
|
||||||
|
<div className="text-[10px] font-black uppercase text-brand tracking-widest">{task.projectName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-text-muted group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="text-center py-10 opacity-50 font-bold">Nenhuma atividade registrada ontem.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reporting Section (Finished Tasks) */}
|
||||||
|
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText size={20} className="text-brand" />
|
||||||
|
<h2 className="text-xl font-black text-text-main">Relatório de Entregas</h2>
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-2 text-sm font-bold text-brand hover:underline bg-brand/5 px-4 py-2 rounded-xl transition-all">
|
||||||
|
<Download size={16} />
|
||||||
|
Baixar PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-soft">
|
||||||
|
<th className="pb-4 text-xs font-black uppercase text-text-muted tracking-widest pl-4">Tarefa</th>
|
||||||
|
<th className="pb-4 text-xs font-black uppercase text-text-muted tracking-widest">Projeto</th>
|
||||||
|
<th className="pb-4 text-xs font-black uppercase text-text-muted tracking-widest">Concluída em</th>
|
||||||
|
<th className="pb-4 text-xs font-black uppercase text-text-muted tracking-widest text-right pr-4">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-soft/50">
|
||||||
|
{data.finishedTasksInPeriod.length > 0 ? data.finishedTasksInPeriod.map(task => (
|
||||||
|
<tr key={task.id} className="group hover:bg-app-bg transition-colors">
|
||||||
|
<td className="py-4 pl-4 font-bold text-text-main">{task.title}</td>
|
||||||
|
<td className="py-4 text-sm font-medium text-text-muted">
|
||||||
|
<span className="px-2 py-1 bg-surface-muted rounded-lg border border-border-soft">
|
||||||
|
{task.projectName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm font-bold text-text-main">
|
||||||
|
{task.finishedAt ? format(new Date(task.finishedAt), 'dd/MM/yyyy HH:mm') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right pr-4">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-lg bg-green-500/10 px-2 py-1 text-[10px] font-black uppercase text-green-600 tracking-tighter">
|
||||||
|
<CheckCircle2 size={12} />
|
||||||
|
Finalizado
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-10 text-center opacity-50 font-bold">Nenhuma tarefa finalizada neste período.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -191,7 +191,7 @@ export function TaskBoardPage() {
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
|
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
|
||||||
<Tag size={12} className="text-brand" />
|
<Tag size={12} className="text-brand" />
|
||||||
{task.project || 'Geral'}
|
{task.projectName || 'Geral'}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-[10px] font-bold text-text-muted/50">
|
<span className="font-mono text-[10px] font-bold text-text-muted/50">
|
||||||
#{task.id}
|
#{task.id}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function TaskListPage() {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
task.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
task.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
task.project?.toLowerCase().includes(searchQuery.toLowerCase())
|
task.projectName?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
|
||||||
const matchesStatus = statusFilter === 'ALL' || task.status === statusFilter
|
const matchesStatus = statusFilter === 'ALL' || task.status === statusFilter
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ export function TaskListPage() {
|
||||||
<div className="flex flex-wrap items-center gap-3 border-t border-border-soft/50 pt-4">
|
<div className="flex flex-wrap items-center gap-3 border-t border-border-soft/50 pt-4">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||||
<Tag size={14} className="text-brand" />
|
<Tag size={14} className="text-brand" />
|
||||||
{task.project || 'Geral'}
|
{task.projectName || 'Geral'}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-border-soft" />
|
<div className="h-4 w-px bg-border-soft" />
|
||||||
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { OrganizationAdminPage } from "./pages/OrganizationAdminPage";
|
||||||
import { MyRequestsPage } from "./pages/MyRequestsPage";
|
import { MyRequestsPage } from "./pages/MyRequestsPage";
|
||||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||||
import { OrganizationsPage } from "./pages/OrganizationsPage";
|
import { OrganizationsPage } from "./pages/OrganizationsPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -62,6 +63,10 @@ export const router = createBrowserRouter([
|
||||||
path: "organizations",
|
path: "organizations",
|
||||||
element: <OrganizationsPage />,
|
element: <OrganizationsPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dashboard",
|
||||||
|
element: <DashboardPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
25
frontend/src/services/dashboardService.ts
Normal file
25
frontend/src/services/dashboardService.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { apiFetch } from '../lib/api'
|
||||||
|
import type { TaskResponse } from '../types/task'
|
||||||
|
|
||||||
|
export interface ProjectStats {
|
||||||
|
projectId: number
|
||||||
|
projectName: string
|
||||||
|
totalMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
totalMinutesWorked: number
|
||||||
|
projectStats: ProjectStats[]
|
||||||
|
yesterdayTasks: TaskResponse[]
|
||||||
|
finishedTasksInPeriod: TaskResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
async getDashboard(startDate?: string, endDate?: string): Promise<DashboardData> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (startDate) params.append('startDate', startDate)
|
||||||
|
if (endDate) params.append('endDate', endDate)
|
||||||
|
|
||||||
|
return apiFetch<DashboardData>(`/dashboard?${params.toString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,8 @@ export type TaskResponse = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
project: string
|
projectName: string
|
||||||
projectId?: number
|
projectId?: number
|
||||||
|
finishedAt?: string
|
||||||
history?: TaskHistoryItem[]
|
history?: TaskHistoryItem[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue