diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/DashboardUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/DashboardUseCaseConfig.java index d702212..1d1837a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/DashboardUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/DashboardUseCaseConfig.java @@ -3,7 +3,10 @@ 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.orgdashboard.OrgDashboardUseCase; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; @ApplicationScoped public class DashboardUseCaseConfig { @@ -12,4 +15,11 @@ public class DashboardUseCaseConfig { public DashboardUseCase dashboardUseCase(TaskRepository taskRepository) { return new DashboardUseCase(taskRepository); } + + @Produces + public OrgDashboardUseCase orgDashboardUseCase(TaskRepository taskRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationAdminUseCase authorizer) { + return new OrgDashboardUseCase(taskRepository, userOrganizationRepository, authorizer); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index d89fd8b..cf939fe 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -63,4 +63,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksByUser(String email) { return find("user.email = ?1", email).list().stream().map(TaskEntityMapper::toDomain).toList(); } + + @Override + public List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { + return find("select distinct t from TaskEntity t join t.history h " + + "where t.project.organization.id = ?1 " + + "and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)", + orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); + } + + @Override + public List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { + return find("select distinct t from TaskEntity t where t.project.organization.id = ?1 " + + "and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " + + "and t.finishedAt >= ?2 and t.finishedAt <= ?3", + orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardController.java new file mode 100644 index 0000000..345ea42 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/OrgDashboardController.java @@ -0,0 +1,52 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard; + +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.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import net.beetechgroup.beetask.usecase.orgdashboard.OrgDashboardInput; +import net.beetechgroup.beetask.usecase.orgdashboard.OrgDashboardOutput; +import net.beetechgroup.beetask.usecase.orgdashboard.OrgDashboardUseCase; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Path("/organizations/{id}/dashboard") +@Produces(MediaType.APPLICATION_JSON) +@Authenticated +public class OrgDashboardController { + + @Inject + OrgDashboardUseCase orgDashboardUseCase; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public OrgDashboardOutput getDashboard( + @PathParam("id") Long organizationId, + @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(); + + if (endDateStr != null && end.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)) { + end = end.with(java.time.LocalTime.MAX); + } + + return orgDashboardUseCase.execute(new OrgDashboardInput(email, organizationId, start, end)); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardInput.java new file mode 100644 index 0000000..523096c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardInput.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +import java.time.LocalDateTime; + +public record OrgDashboardInput( + String userEmail, + Long organizationId, + LocalDateTime startDate, + LocalDateTime endDate +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java new file mode 100644 index 0000000..8914eb6 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardOutput.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +import java.util.List; + +public record OrgDashboardOutput( + List projectStats, + List topTasks, + List memberStats +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java new file mode 100644 index 0000000..ed361ec --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgDashboardUseCase.java @@ -0,0 +1,124 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; +import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.entities.task.TaskHistoryItem; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; +import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; + +public class OrgDashboardUseCase { + + private final TaskRepository taskRepository; + private final UserOrganizationRepository userOrganizationRepository; + private final AuthorizeOrganizationAdminUseCase authorizer; + + public OrgDashboardUseCase(TaskRepository taskRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationAdminUseCase authorizer) { + this.taskRepository = taskRepository; + this.userOrganizationRepository = userOrganizationRepository; + this.authorizer = authorizer; + } + + public OrgDashboardOutput execute(OrgDashboardInput input) { + authorizer.execute(input.userEmail(), input.organizationId()); + + List workedTasks = taskRepository.findTasksWorkedByOrgInPeriod( + input.organizationId(), input.startDate(), input.endDate() + ); + List finishedTasks = taskRepository.findTasksFinishedByOrgInPeriod( + input.organizationId(), input.startDate(), input.endDate() + ); + + Map projectStatsMap = new HashMap<>(); + Map memberMinutesMap = new HashMap<>(); + + for (Task task : workedTasks) { + long minutes = calculateMinutesInPeriod(task, input.startDate(), input.endDate()); + + if (task.getProject() != null) { + Long projectId = task.getProject().getId(); + OrgProjectStats existing = projectStatsMap.getOrDefault(projectId, + new OrgProjectStats(projectId, task.getProject().getName(), 0L)); + projectStatsMap.put(projectId, new OrgProjectStats( + projectId, existing.projectName(), existing.totalMinutes() + minutes)); + } + + if (task.getUser() != null) { + memberMinutesMap.merge(task.getUser().getEmail(), minutes, Long::sum); + } + } + + List topTasks = workedTasks.stream() + .map(t -> { + long minutes = calculateMinutesInPeriod(t, input.startDate(), input.endDate()); + String projectName = t.getProject() != null ? t.getProject().getName() : "-"; + return new OrgTopTask(t.getId(), t.getTitle(), projectName, minutes); + }) + .sorted(Comparator.comparingLong(OrgTopTask::totalMinutes).reversed()) + .limit(5) + .toList(); + + Map finishedByMember = new HashMap<>(); + for (Task task : finishedTasks) { + if (task.getUser() != null) { + finishedByMember.merge(task.getUser().getEmail(), 1L, Long::sum); + } + } + + List members = userOrganizationRepository.findByOrganizationIdAndStatus( + input.organizationId(), UserOrganizationStatus.ACTIVE + ); + + List memberStats = members.stream() + .map(uo -> { + String email = uo.getUser().getEmail(); + return new OrgMemberStats( + uo.getUser().getId(), + uo.getUser().getName(), + uo.getUser().getPhoto(), + finishedByMember.getOrDefault(email, 0L), + memberMinutesMap.getOrDefault(email, 0L) + ); + }) + .sorted(Comparator.comparingLong(OrgMemberStats::totalMinutesWorked).reversed()) + .toList(); + + return new OrgDashboardOutput( + new ArrayList<>(projectStatsMap.values()), + topTasks, + memberStats + ); + } + + private long calculateMinutesInPeriod(Task task, LocalDateTime start, LocalDateTime end) { + return task.getHistory().stream() + .filter(h -> isWithinPeriod(h, start, end)) + .mapToLong(h -> calculateMinutesInRange(h, start, end)) + .sum(); + } + + private boolean isWithinPeriod(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) { + if (item.getStartAt() == null) return false; + 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(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgMemberStats.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgMemberStats.java new file mode 100644 index 0000000..1bfd9d7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgMemberStats.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +public record OrgMemberStats( + Long userId, + String userName, + String userPhoto, + long finishedTasksCount, + long totalMinutesWorked +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgProjectStats.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgProjectStats.java new file mode 100644 index 0000000..bc9cf28 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgProjectStats.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +public record OrgProjectStats(Long projectId, String projectName, long totalMinutes) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgTopTask.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgTopTask.java new file mode 100644 index 0000000..897b453 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/OrgTopTask.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.orgdashboard; + +public record OrgTopTask(Long taskId, String title, String projectName, long totalMinutes) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index 55f0299..d22b424 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -12,4 +12,6 @@ public interface TaskRepository { List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); + List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); + List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); } \ No newline at end of file diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 90963cb..d80da52 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -12,6 +12,7 @@ import { Shield, FolderKanban, BarChart3, + PieChart, } from "lucide-react"; import { NavLink, Outlet, Navigate, Link } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; @@ -259,6 +260,14 @@ export function AppShell() { )} + {(activeOrg?.roles.includes("OWNER") || + activeOrg?.roles.includes("ADMIN")) && ( + + + Org Dashboard + + )} + {/* Organizações Dropdown */}
)} + {(activeOrg?.roles.includes("OWNER") || + activeOrg?.roles.includes("ADMIN")) && ( + + cn(navLinkClassName({ isActive }), "flex-col gap-0.5 py-1") + } + > + + Org + + )} + {/* Mobile Organizações Group */}
+
+ + + {isLoading ? ( +
+ +

Carregando dados da organização...

+
+ ) : error ? ( +
+ +

Ocorreu um erro

+

{error}

+ +
+ ) : data ? ( + <> + {/* Pie chart + Top Tasks */} +
+ {/* Pie Chart — Projects */} +
+
+ +

Tempo por Projeto

+
+ +
+
+ +
+ +
+ {data.projectStats.length > 0 ? ( + data.projectStats + .slice() + .sort((a, b) => b.totalMinutes - a.totalMinutes) + .map((stat, i) => { + const pct = totalMinutes > 0 ? ((stat.totalMinutes / totalMinutes) * 100).toFixed(1) : "0"; + return ( +
+
+
+
+ {stat.projectName} + + {formatMinutes(stat.totalMinutes)} ({pct}%) + +
+
+ +
+
+
+ ); + }) + ) : ( +

+ Nenhum projeto com atividade no período. +

+ )} +
+
+
+ + {/* Top 5 Tasks */} +
+
+ +

Top 5 Tarefas

+ + por tempo + +
+ +
+ {data.topTasks.length > 0 ? ( + data.topTasks.map((task, idx) => ( + +
+ {idx + 1} +
+
+
{task.title}
+
+ {task.projectName} +
+
+
+ + {formatMinutes(task.totalMinutes)} +
+
+ )) + ) : ( +

+ Nenhuma tarefa com atividade no período. +

+ )} +
+
+
+ + {/* Members Section */} +
+
+ +

Membros do Time

+ + ordenado por tempo + +
+ + {data.memberStats.length === 0 ? ( +

+ Nenhum membro encontrado. +

+ ) : ( +
+ {data.memberStats.map((member, idx) => { + const isFirst = idx === 0; + return ( + + {isFirst && ( +
+ + Top 1 +
+ )} + + + +
+
+ {member.userName} +
+
+ +
+
+
+ +
+
+ {formatMinutes(member.totalMinutesWorked)} +
+
+ Trabalhado +
+
+ +
+
+ +
+
+ {member.finishedTasksCount} +
+
+ Concluídas +
+
+
+
+ ); + })} +
+ )} +
+ + ) : null} +
+ ); +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 32e455f..2abd3d7 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,6 +13,7 @@ import { MyRequestsPage } from "./pages/MyRequestsPage"; import { ProjectsPage } from "./pages/ProjectsPage"; import { OrganizationsPage } from "./pages/OrganizationsPage"; import { DashboardPage } from "./pages/DashboardPage"; +import { OrgDashboardPage } from "./pages/OrgDashboardPage"; import { RoleGate } from "./components/RoleGate"; export const router = createBrowserRouter([ @@ -80,6 +81,14 @@ export const router = createBrowserRouter([ path: "dashboard", element: , }, + { + path: "org-dashboard", + element: ( + + + + ), + }, ], }, ]); diff --git a/frontend/src/services/orgDashboardService.ts b/frontend/src/services/orgDashboardService.ts new file mode 100644 index 0000000..19bb567 --- /dev/null +++ b/frontend/src/services/orgDashboardService.ts @@ -0,0 +1,37 @@ +import { apiFetch } from '../lib/api' + +export interface OrgProjectStats { + projectId: number + projectName: string + totalMinutes: number +} + +export interface OrgTopTask { + taskId: number + title: string + projectName: string + totalMinutes: number +} + +export interface OrgMemberStats { + userId: number + userName: string + userPhoto: string | null + finishedTasksCount: number + totalMinutesWorked: number +} + +export interface OrgDashboardData { + projectStats: OrgProjectStats[] + topTasks: OrgTopTask[] + memberStats: OrgMemberStats[] +} + +export const orgDashboardService = { + async getDashboard(orgId: number, startDate?: string, endDate?: string): Promise { + const params = new URLSearchParams() + if (startDate) params.append('startDate', startDate) + if (endDate) params.append('endDate', endDate) + return apiFetch(`/organizations/${orgId}/dashboard?${params.toString()}`) + } +}