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 1d1837a..b38057f 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 @@ -4,6 +4,7 @@ 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.orgdashboard.memberdetail.MemberDetailUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; @@ -22,4 +23,11 @@ public class DashboardUseCaseConfig { AuthorizeOrganizationAdminUseCase authorizer) { return new OrgDashboardUseCase(taskRepository, userOrganizationRepository, authorizer); } + + @Produces + public MemberDetailUseCase memberDetailUseCase(TaskRepository taskRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationAdminUseCase authorizer) { + return new MemberDetailUseCase(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 cf939fe..57dab7c 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 @@ -79,4 +79,22 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository= ?2 and t.finishedAt <= ?3", orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); } + + @Override + public List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return find("user.id = ?1", userId).list().stream() + .filter(entity -> entity.getHistory().stream().anyMatch(h -> + Objects.nonNull(h.getStartAt()) && + !h.getStartAt().isAfter(end) && + (Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start)))) + .map(TaskEntityMapper::toDomain) + .toList(); + } + + @Override + public List findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", + userId, TaskStatus.COMPLETED, start, end) + .list().stream().map(TaskEntityMapper::toDomain).toList(); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailController.java new file mode 100644 index 0000000..f0b944d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailController.java @@ -0,0 +1,36 @@ +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.core.MediaType; +import net.beetechgroup.beetask.usecase.orgdashboard.memberdetail.MemberDetailInput; +import net.beetechgroup.beetask.usecase.orgdashboard.memberdetail.MemberDetailUseCase; + +@Path("/organizations/{id}/members/{memberId}/stats") +@Produces(MediaType.APPLICATION_JSON) +@Authenticated +public class MemberDetailController { + + @Inject + MemberDetailUseCase memberDetailUseCase; + + @Inject + SecurityIdentity securityIdentity; + + @GET + public MemberDetailResponse getMemberStats( + @PathParam("id") Long organizationId, + @PathParam("memberId") Long memberId) { + + String email = securityIdentity.getPrincipal().getName(); + + return MemberDetailControllerMapper.toResponse( + memberDetailUseCase.execute(new MemberDetailInput(email, organizationId, memberId)) + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java new file mode 100644 index 0000000..cffe548 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailControllerMapper.java @@ -0,0 +1,19 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard; + +import net.beetechgroup.beetask.usecase.orgdashboard.memberdetail.MemberDetailOutput; + +public class MemberDetailControllerMapper { + + public static MemberDetailResponse toResponse(MemberDetailOutput output) { + return new MemberDetailResponse( + output.userId(), + output.userName(), + output.userEmail(), + output.userPhoto(), + output.monthlyStats().stream() + .map(s -> new MemberDetailResponse.MonthlyStatsResponse( + s.year(), s.month(), s.finishedTasksCount(), s.totalMinutesWorked())) + .toList() + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java new file mode 100644 index 0000000..7509ca2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/orgdashboard/MemberDetailResponse.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.orgdashboard; + +import java.util.List; + +public record MemberDetailResponse( + Long userId, + String userName, + String userEmail, + String userPhoto, + List monthlyStats +) { + public record MonthlyStatsResponse(int year, int month, long finishedTasksCount, long totalMinutesWorked) {} +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailInput.java new file mode 100644 index 0000000..6a27260 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailInput.java @@ -0,0 +1,4 @@ +package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail; + +public record MemberDetailInput(String userEmail, Long organizationId, Long memberId) { +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java new file mode 100644 index 0000000..ddf6ed4 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailMapper.java @@ -0,0 +1,18 @@ +package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail; + +import net.beetechgroup.beetask.entities.organization.UserOrganization; + +import java.util.List; + +public class MemberDetailMapper { + + public static MemberDetailOutput toOutput(UserOrganization member, List monthlyStats) { + return new MemberDetailOutput( + member.getUser().getId(), + member.getUser().getName(), + member.getUser().getEmail(), + member.getUser().getPhoto(), + monthlyStats + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java new file mode 100644 index 0000000..1e3b941 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailOutput.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail; + +import java.util.List; + +public record MemberDetailOutput( + Long userId, + String userName, + String userEmail, + String userPhoto, + List monthlyStats +) { + public record MonthlyStats(int year, int month, long finishedTasksCount, long totalMinutesWorked) {} +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java new file mode 100644 index 0000000..40c5419 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/orgdashboard/memberdetail/MemberDetailUseCase.java @@ -0,0 +1,93 @@ +package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail; + +import net.beetechgroup.beetask.entities.organization.UserOrganization; +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; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MemberDetailUseCase { + + private final TaskRepository taskRepository; + private final UserOrganizationRepository userOrganizationRepository; + private final AuthorizeOrganizationAdminUseCase authorizer; + + public MemberDetailUseCase(TaskRepository taskRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationAdminUseCase authorizer) { + this.taskRepository = taskRepository; + this.userOrganizationRepository = userOrganizationRepository; + this.authorizer = authorizer; + } + + public MemberDetailOutput execute(MemberDetailInput input) { + authorizer.execute(input.userEmail(), input.organizationId()); + + UserOrganization member = userOrganizationRepository + .findByUserAndOrganization(input.memberId(), input.organizationId()) + .orElseThrow(() -> new IllegalArgumentException("Member not found in organization")); + + LocalDateTime end = LocalDateTime.now(); + LocalDateTime start = end.minusMonths(5).withDayOfMonth(1).toLocalDate().atStartOfDay(); + + List workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end); + List finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end); + + Map minutesPerMonth = new HashMap<>(); + Map finishedPerMonth = new HashMap<>(); + + for (Task task : workedTasks) { + for (TaskHistoryItem h : task.getHistory()) { + if (!isWithinPeriod(h, start, end)) continue; + LocalDateTime effectiveStart = h.getStartAt().isBefore(start) ? start : h.getStartAt(); + LocalDateTime effectiveEnd = (h.getEndAt() == null || h.getEndAt().isAfter(end)) ? end : h.getEndAt(); + if (effectiveStart.isAfter(effectiveEnd)) continue; + + YearMonth ym = YearMonth.from(effectiveStart); + long minutes = Duration.between(effectiveStart, effectiveEnd).toMinutes(); + minutesPerMonth.merge(ym, minutes, Long::sum); + } + } + + for (Task task : finishedTasks) { + if (task.getFinishedAt() == null) continue; + YearMonth ym = YearMonth.from(task.getFinishedAt()); + finishedPerMonth.merge(ym, 1L, Long::sum); + } + + List monthlyStats = new ArrayList<>(); + YearMonth current = YearMonth.from(start); + YearMonth endMonth = YearMonth.from(end); + while (!current.isAfter(endMonth)) { + monthlyStats.add(new MemberDetailOutput.MonthlyStats( + current.getYear(), + current.getMonthValue(), + finishedPerMonth.getOrDefault(current, 0L), + minutesPerMonth.getOrDefault(current, 0L) + )); + current = current.plusMonths(1); + } + + monthlyStats.sort(Comparator.comparingInt(MemberDetailOutput.MonthlyStats::year) + .thenComparingInt(MemberDetailOutput.MonthlyStats::month)); + + return MemberDetailMapper.toOutput(member, monthlyStats); + } + + 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; + } +} 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 d22b424..d9a20c2 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 @@ -14,4 +14,6 @@ public interface TaskRepository { List findTasksByUser(String email); List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); + List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); + List findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); } \ No newline at end of file diff --git a/frontend/src/components/MemberDetailModal.tsx b/frontend/src/components/MemberDetailModal.tsx new file mode 100644 index 0000000..9a923f7 --- /dev/null +++ b/frontend/src/components/MemberDetailModal.tsx @@ -0,0 +1,243 @@ +import React, { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle } from "lucide-react"; +import { orgDashboardService, type MemberDetailData, type MonthlyStats } from "../services/orgDashboardService"; + +const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]; + +const BAR_COLOR = "#7C3AED"; +const BAR_COLOR_2 = "#06B6D4"; + +function Avatar({ name, photo }: { name: string; photo?: string | null }) { + if (photo) { + return {name}; + } + const initials = name + .split(" ") + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +function formatMinutes(minutes: number) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + return `${h}h ${m}m`; +} + +function BarChart({ + data, + valueKey, + color, + formatValue, +}: { + data: MonthlyStats[]; + valueKey: "finishedTasksCount" | "totalMinutesWorked"; + color: string; + formatValue: (v: number) => string; +}) { + const values = data.map((d) => d[valueKey] as number); + const maxVal = Math.max(...values, 1); + + const chartW = 320; + const chartH = 120; + const barAreaH = 90; + const barCount = data.length; + const gap = 6; + const barW = barCount > 0 ? Math.floor((chartW - gap * (barCount - 1)) / barCount) : 30; + + return ( + + {data.map((d, i) => { + const val = d[valueKey] as number; + const barH = maxVal > 0 ? Math.round((val / maxVal) * barAreaH) : 0; + const x = i * (barW + gap); + const y = barAreaH - barH; + const label = MONTH_NAMES[(d.month - 1) % 12]; + + return ( + + + {val > 0 && ( + + {formatValue(val)} + + )} + + {label} + + + ); + })} + + ); +} + +interface MemberDetailModalProps { + memberId: number | null; + orgId: number; + onClose: () => void; +} + +export const MemberDetailModal: React.FC = ({ memberId, orgId, onClose }) => { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (memberId === null) { + setData(null); + return; + } + setIsLoading(true); + setError(null); + setData(null); + orgDashboardService + .getMemberStats(orgId, memberId) + .then(setData) + .catch(() => setError("Falha ao carregar os dados do membro.")) + .finally(() => setIsLoading(false)); + }, [memberId, orgId]); + + return ( + + {memberId !== null && ( + +
+ + + + +
+ {isLoading ? ( +
+ +

Carregando dados...

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : data ? ( +
+ {/* Header */} +
+ +
+

{data.userName}

+
+ + {data.userEmail} +
+
+
+ + {/* Summary chips */} +
+
+ +
+ {data.monthlyStats.reduce((s, m) => s + m.finishedTasksCount, 0)} +
+
+ Concluídas (6m) +
+
+
+ +
+ {formatMinutes(data.monthlyStats.reduce((s, m) => s + m.totalMinutesWorked, 0))} +
+
+ Trabalhado (6m) +
+
+
+ + {/* Chart: finished tasks per month */} +
+
+ + Tarefas Concluídas por Mês +
+ String(v)} + /> +
+ + {/* Chart: minutes worked per month */} +
+
+ + Tempo Trabalhado por Mês +
+ +
+
+ ) : null} +
+
+ + )} + + ); +}; diff --git a/frontend/src/pages/OrgDashboardPage.tsx b/frontend/src/pages/OrgDashboardPage.tsx index d7dca81..1ca97fb 100644 --- a/frontend/src/pages/OrgDashboardPage.tsx +++ b/frontend/src/pages/OrgDashboardPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useCallback } from "react"; +import { MemberDetailModal } from "../components/MemberDetailModal"; import { motion } from "framer-motion"; import { BarChart3, @@ -134,6 +135,7 @@ export const OrgDashboardPage: React.FC = () => { const [startDate, setStartDate] = useState(startOfMonth(new Date())); const [endDate, setEndDate] = useState(endOfMonth(new Date())); const [error, setError] = useState(null); + const [selectedMemberId, setSelectedMemberId] = useState(null); const fetchData = useCallback(async () => { if (!activeOrg) return; @@ -361,10 +363,11 @@ export const OrgDashboardPage: React.FC = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: idx * 0.06 }} + onClick={() => setSelectedMemberId(member.userId)} className={cn( - "relative rounded-[1.5rem] p-6 border transition-all flex flex-col items-center text-center gap-3", + "relative rounded-[1.5rem] p-6 border transition-all flex flex-col items-center text-center gap-3 cursor-pointer", isFirst - ? "border-amber-400/50 bg-gradient-to-b from-amber-500/10 to-amber-500/5 shadow-xl shadow-amber-500/10" + ? "border-amber-400/50 bg-gradient-to-b from-amber-500/10 to-amber-500/5 shadow-xl shadow-amber-500/10 hover:shadow-2xl" : "border-border-soft bg-app-bg hover:border-brand/30 hover:shadow-md", )} > @@ -416,6 +419,14 @@ export const OrgDashboardPage: React.FC = () => { ) : null} + + {activeOrg && ( + setSelectedMemberId(null)} + /> + )}
); }; diff --git a/frontend/src/services/orgDashboardService.ts b/frontend/src/services/orgDashboardService.ts index 19bb567..b950b55 100644 --- a/frontend/src/services/orgDashboardService.ts +++ b/frontend/src/services/orgDashboardService.ts @@ -27,11 +27,30 @@ export interface OrgDashboardData { memberStats: OrgMemberStats[] } +export interface MonthlyStats { + year: number + month: number + finishedTasksCount: number + totalMinutesWorked: number +} + +export interface MemberDetailData { + userId: number + userName: string + userEmail: string + userPhoto: string | null + monthlyStats: MonthlyStats[] +} + 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()}`) + }, + + async getMemberStats(orgId: number, memberId: number): Promise { + return apiFetch(`/organizations/${orgId}/members/${memberId}/stats`) } }