dashboard

This commit is contained in:
Gabriel Santana de Menezes 2026-05-03 22:24:58 -04:00
parent 14b9b6985a
commit 1688cf092e
30 changed files with 696 additions and 31 deletions

View file

@ -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);
} }

View file

@ -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;
}
} }

View file

@ -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);
}
}

View file

@ -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

View file

@ -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;
} }

View file

@ -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;
}
} }

View file

@ -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()));

View file

@ -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;
} }
} }

View file

@ -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();
}
} }

View file

@ -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);
}
}

View file

@ -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) {
} }

View file

@ -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);
} }

View file

@ -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) {

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.dashboard;
import java.time.LocalDateTime;
public record DashboardInput(
String userEmail,
LocalDateTime startDate,
LocalDateTime endDate
) {}

View file

@ -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
) {}

View file

@ -0,0 +1,7 @@
package net.beetechgroup.beetask.usecase.dashboard;
public record DashboardProjectStats(
Long projectId,
String projectName,
Long totalMinutes
) {}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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);
} }

View file

@ -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());
} }

View file

@ -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) {
} }

View file

@ -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) {}
}

View file

@ -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));
} }
} }

View file

@ -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

View 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>
);
};

View file

@ -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}

View file

@ -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">

View file

@ -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 />,
},
], ],
}, },
]); ]);

View 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()}`)
}
}

View file

@ -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[]
} }