chore: removing unecessary lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
01be6bf845
commit
824d33f283
10 changed files with 338 additions and 179 deletions
|
|
@ -7,10 +7,14 @@ 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.memberdetail.MemberDetailInput;
|
||||
import net.beetechgroup.beetask.usecase.orgdashboard.memberdetail.MemberDetailUseCase;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Path("/organizations/{id}/members/{memberId}/stats")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Authenticated
|
||||
|
|
@ -25,12 +29,26 @@ public class MemberDetailController {
|
|||
@GET
|
||||
public MemberDetailResponse getMemberStats(
|
||||
@PathParam("id") Long organizationId,
|
||||
@PathParam("memberId") Long memberId) {
|
||||
@PathParam("memberId") Long memberId,
|
||||
@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 MemberDetailControllerMapper.toResponse(
|
||||
memberDetailUseCase.execute(new MemberDetailInput(email, organizationId, memberId))
|
||||
memberDetailUseCase.execute(new MemberDetailInput(email, organizationId, memberId, start, end))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ public class MemberDetailControllerMapper {
|
|||
output.userName(),
|
||||
output.userEmail(),
|
||||
output.userPhoto(),
|
||||
output.monthlyStats().stream()
|
||||
.map(s -> new MemberDetailResponse.MonthlyStatsResponse(
|
||||
s.year(), s.month(), s.finishedTasksCount(), s.totalMinutesWorked()))
|
||||
output.groupedBy(),
|
||||
output.periodStats().stream()
|
||||
.map(s -> new MemberDetailResponse.PeriodStatsResponse(
|
||||
s.year(), s.month(), s.day(), s.finishedTasksCount(), s.totalMinutesWorked()))
|
||||
.toList(),
|
||||
output.projectStats().stream()
|
||||
.map(p -> new MemberDetailResponse.ProjectStatsResponse(
|
||||
p.projectId(), p.projectName(), p.totalMinutes()))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ public record MemberDetailResponse(
|
|||
String userName,
|
||||
String userEmail,
|
||||
String userPhoto,
|
||||
List<MonthlyStatsResponse> monthlyStats
|
||||
String groupedBy,
|
||||
List<PeriodStatsResponse> periodStats,
|
||||
List<ProjectStatsResponse> projectStats
|
||||
) {
|
||||
public record MonthlyStatsResponse(int year, int month, long finishedTasksCount, long totalMinutesWorked) {}
|
||||
public record PeriodStatsResponse(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {}
|
||||
public record ProjectStatsResponse(Long projectId, String projectName, long totalMinutes) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail;
|
||||
|
||||
public record MemberDetailInput(String userEmail, Long organizationId, Long memberId) {
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record MemberDetailInput(String userEmail, Long organizationId, Long memberId,
|
||||
LocalDateTime startDate, LocalDateTime endDate) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ import java.util.List;
|
|||
|
||||
public class MemberDetailMapper {
|
||||
|
||||
public static MemberDetailOutput toOutput(UserOrganization member, List<MemberDetailOutput.MonthlyStats> monthlyStats) {
|
||||
public static MemberDetailOutput toOutput(UserOrganization member,
|
||||
String groupedBy,
|
||||
List<MemberDetailOutput.PeriodStats> periodStats,
|
||||
List<MemberDetailOutput.ProjectStats> projectStats) {
|
||||
return new MemberDetailOutput(
|
||||
member.getUser().getId(),
|
||||
member.getUser().getName(),
|
||||
member.getUser().getEmail(),
|
||||
member.getUser().getPhoto(),
|
||||
monthlyStats
|
||||
groupedBy,
|
||||
periodStats,
|
||||
projectStats
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ public record MemberDetailOutput(
|
|||
String userName,
|
||||
String userEmail,
|
||||
String userPhoto,
|
||||
List<MonthlyStats> monthlyStats
|
||||
String groupedBy,
|
||||
List<PeriodStats> periodStats,
|
||||
List<ProjectStats> projectStats
|
||||
) {
|
||||
public record MonthlyStats(int year, int month, long finishedTasksCount, long totalMinutesWorked) {}
|
||||
public record PeriodStats(int year, int month, Integer day, long finishedTasksCount, long totalMinutesWorked) {}
|
||||
public record ProjectStats(Long projectId, String projectName, long totalMinutes) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
|||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MemberDetailUseCase {
|
||||
|
||||
|
|
@ -37,57 +39,90 @@ public class MemberDetailUseCase {
|
|||
.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();
|
||||
LocalDateTime start = input.startDate();
|
||||
LocalDateTime end = input.endDate();
|
||||
|
||||
long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate());
|
||||
boolean groupByDay = daySpan <= 30;
|
||||
|
||||
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end);
|
||||
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
|
||||
|
||||
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
|
||||
Map<LocalDate, Long> minutesPerDay = new HashMap<>();
|
||||
Map<YearMonth, Long> minutesPerMonth = new HashMap<>();
|
||||
Map<YearMonth, Long> 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();
|
||||
LocalDateTime effectiveEnd = (Objects.isNull(h.getEndAt()) || 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);
|
||||
|
||||
if (groupByDay) {
|
||||
minutesPerDay.merge(effectiveStart.toLocalDate(), minutes, Long::sum);
|
||||
} else {
|
||||
minutesPerMonth.merge(YearMonth.from(effectiveStart), minutes, Long::sum);
|
||||
}
|
||||
|
||||
if (Objects.nonNull(task.getProject())) {
|
||||
Long projectId = task.getProject().getId();
|
||||
MemberDetailOutput.ProjectStats existing = projectStatsMap.getOrDefault(projectId,
|
||||
new MemberDetailOutput.ProjectStats(projectId, task.getProject().getName(), 0L));
|
||||
projectStatsMap.put(projectId, new MemberDetailOutput.ProjectStats(
|
||||
projectId, existing.projectName(), existing.totalMinutes() + minutes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<MemberDetailOutput.PeriodStats> periodStats = new ArrayList<>();
|
||||
|
||||
if (groupByDay) {
|
||||
Map<LocalDate, Long> finishedPerDay = new HashMap<>();
|
||||
for (Task task : finishedTasks) {
|
||||
if (task.getFinishedAt() == null) continue;
|
||||
YearMonth ym = YearMonth.from(task.getFinishedAt());
|
||||
finishedPerMonth.merge(ym, 1L, Long::sum);
|
||||
if (Objects.isNull(task.getFinishedAt())) continue;
|
||||
finishedPerDay.merge(task.getFinishedAt().toLocalDate(), 1L, Long::sum);
|
||||
}
|
||||
|
||||
LocalDate current = start.toLocalDate();
|
||||
LocalDate endDate = end.toLocalDate();
|
||||
while (!current.isAfter(endDate)) {
|
||||
periodStats.add(new MemberDetailOutput.PeriodStats(
|
||||
current.getYear(), current.getMonthValue(), current.getDayOfMonth(),
|
||||
finishedPerDay.getOrDefault(current, 0L),
|
||||
minutesPerDay.getOrDefault(current, 0L)
|
||||
));
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
} else {
|
||||
Map<YearMonth, Long> finishedPerMonth = new HashMap<>();
|
||||
for (Task task : finishedTasks) {
|
||||
if (Objects.isNull(task.getFinishedAt())) continue;
|
||||
finishedPerMonth.merge(YearMonth.from(task.getFinishedAt()), 1L, Long::sum);
|
||||
}
|
||||
|
||||
List<MemberDetailOutput.MonthlyStats> 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(),
|
||||
periodStats.add(new MemberDetailOutput.PeriodStats(
|
||||
current.getYear(), current.getMonthValue(), null,
|
||||
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);
|
||||
return MemberDetailMapper.toOutput(member, groupByDay ? "DAY" : "MONTH", periodStats,
|
||||
new ArrayList<>(projectStatsMap.values()));
|
||||
}
|
||||
|
||||
private boolean isWithinPeriod(TaskHistoryItem item, LocalDateTime start, LocalDateTime end) {
|
||||
if (item.getStartAt() == null) return false;
|
||||
if (Objects.isNull(item.getStartAt())) return false;
|
||||
if (item.getStartAt().isAfter(end)) return false;
|
||||
if (item.getEndAt() != null && item.getEndAt().isBefore(start)) return false;
|
||||
if (Objects.nonNull(item.getEndAt()) && item.getEndAt().isBefore(start)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,102 +1,83 @@
|
|||
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";
|
||||
import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle, TrendingUp, Zap } from "lucide-react";
|
||||
import { format, differenceInDays } from "date-fns";
|
||||
import {
|
||||
orgDashboardService,
|
||||
type MemberDetailData,
|
||||
type PeriodStats,
|
||||
type MemberProjectStats,
|
||||
} 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 <img src={photo} alt={name} className="w-20 h-20 rounded-3xl object-cover" />;
|
||||
}
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return (
|
||||
<div className="w-20 h-20 rounded-3xl bg-brand/10 text-brand font-black text-2xl flex items-center justify-center">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"];
|
||||
|
||||
function formatMinutes(minutes: number) {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function getPeriodLabel(s: PeriodStats, groupedBy: "DAY" | "MONTH"): string {
|
||||
if (groupedBy === "DAY") {
|
||||
return `${String(s.day).padStart(2, "0")}/${String(s.month).padStart(2, "0")}`;
|
||||
}
|
||||
return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`;
|
||||
}
|
||||
|
||||
function Avatar({ name, photo }: { name: string; photo?: string | null }) {
|
||||
if (photo) return <img src={photo} alt={name} className="w-14 h-14 rounded-2xl object-cover shrink-0" />;
|
||||
const initials = name.split(" ").slice(0, 2).map((n) => n[0]).join("").toUpperCase();
|
||||
return (
|
||||
<div className="w-14 h-14 rounded-2xl bg-brand/10 text-brand font-black text-xl flex items-center justify-center shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarChart({
|
||||
data,
|
||||
valueKey,
|
||||
color,
|
||||
formatValue,
|
||||
groupedBy,
|
||||
}: {
|
||||
data: MonthlyStats[];
|
||||
data: PeriodStats[];
|
||||
valueKey: "finishedTasksCount" | "totalMinutesWorked";
|
||||
color: string;
|
||||
formatValue: (v: number) => string;
|
||||
groupedBy: "DAY" | "MONTH";
|
||||
}) {
|
||||
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 chartW = 220;
|
||||
const barAreaH = 80;
|
||||
const barCount = data.length;
|
||||
const gap = 6;
|
||||
const barW = barCount > 0 ? Math.floor((chartW - gap * (barCount - 1)) / barCount) : 30;
|
||||
const gap = barCount > 15 ? 2 : 4;
|
||||
const barW = barCount > 0 ? Math.max(2, Math.floor((chartW - gap * (barCount - 1)) / barCount)) : 10;
|
||||
|
||||
const showLabel = (i: number) => {
|
||||
if (groupedBy === "DAY") return barCount <= 10 || i % Math.ceil(barCount / 6) === 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${chartW} ${chartH + 20}`} className="w-full">
|
||||
<svg viewBox={`0 0 ${chartW} ${barAreaH + 18}`} className="w-full">
|
||||
{data.map((d, i) => {
|
||||
const val = d[valueKey] as number;
|
||||
const barH = maxVal > 0 ? Math.round((val / maxVal) * barAreaH) : 0;
|
||||
const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0;
|
||||
const x = i * (barW + gap);
|
||||
const y = barAreaH - barH;
|
||||
const label = MONTH_NAMES[(d.month - 1) % 12];
|
||||
const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : "";
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barW}
|
||||
height={barH}
|
||||
rx={4}
|
||||
fill={color}
|
||||
opacity={val === 0 ? 0.15 : 0.85}
|
||||
/>
|
||||
{val > 0 && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={y - 3}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
fill="currentColor"
|
||||
opacity="0.6"
|
||||
>
|
||||
{formatValue(val)}
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={barAreaH + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
fill="currentColor"
|
||||
opacity="0.5"
|
||||
>
|
||||
<rect x={x} y={y} width={barW} height={barH} rx={2} fill={color} opacity={val === 0 ? 0.12 : 0.85} />
|
||||
{label && (
|
||||
<text x={x + barW / 2} y={barAreaH + 12} textAnchor="middle" fontSize="7" fontWeight="700" fill="currentColor" opacity="0.45">
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
|
@ -104,31 +85,89 @@ function BarChart({
|
|||
);
|
||||
}
|
||||
|
||||
function MiniPieChart({ data }: { data: MemberProjectStats[] }) {
|
||||
const total = data.reduce((s, d) => s + d.totalMinutes, 0);
|
||||
if (total === 0 || data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full opacity-40 text-xs font-bold">
|
||||
Sem dados
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cx = 50, cy = 50, r = 38, innerR = 22;
|
||||
let startAngle = -Math.PI / 2;
|
||||
|
||||
const slices = data.map((d, i) => {
|
||||
const fraction = d.totalMinutes / total;
|
||||
const angle = fraction * 2 * Math.PI;
|
||||
const endAngle = startAngle + angle;
|
||||
const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
|
||||
const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle);
|
||||
const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle);
|
||||
const largeArc = angle > Math.PI ? 1 : 0;
|
||||
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`;
|
||||
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], name: d.projectName, fraction };
|
||||
startAngle = endAngle;
|
||||
return result;
|
||||
});
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{slices.map((s, i) => (
|
||||
<path key={i} d={s.path} fill={s.color} stroke="var(--color-surface,#fff)" strokeWidth="1.5" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface MemberDetailModalProps {
|
||||
memberId: number | null;
|
||||
orgId: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({ memberId, orgId, onClose }) => {
|
||||
export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
|
||||
memberId,
|
||||
orgId,
|
||||
startDate,
|
||||
endDate,
|
||||
onClose,
|
||||
}) => {
|
||||
const [data, setData] = useState<MemberDetailData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (memberId === null) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
if (memberId === null) { setData(null); return; }
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
const start = format(startDate, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
const end = format(endDate, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
orgDashboardService
|
||||
.getMemberStats(orgId, memberId)
|
||||
.getMemberStats(orgId, memberId, start, end)
|
||||
.then(setData)
|
||||
.catch(() => setError("Falha ao carregar os dados do membro."))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [memberId, orgId]);
|
||||
}, [memberId, orgId, startDate, endDate]);
|
||||
|
||||
const totalMinutes = data?.periodStats.reduce((s, p) => s + p.totalMinutesWorked, 0) ?? 0;
|
||||
const totalFinished = data?.periodStats.reduce((s, p) => s + p.finishedTasksCount, 0) ?? 0;
|
||||
|
||||
const daySpan = Math.max(differenceInDays(endDate, startDate), 1);
|
||||
const avgPerDay = totalMinutes / daySpan;
|
||||
|
||||
const bestPeriod = data?.periodStats.reduce(
|
||||
(best, p) => (p.totalMinutesWorked > (best?.totalMinutesWorked ?? -1) ? p : best),
|
||||
null as PeriodStats | null,
|
||||
);
|
||||
const bestLabel = bestPeriod && bestPeriod.totalMinutesWorked > 0
|
||||
? getPeriodLabel(bestPeriod, data!.groupedBy)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
@ -139,100 +178,136 @@ export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({ memberId,
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<motion.div
|
||||
className="relative z-10 w-full max-w-lg bg-surface border border-border-soft rounded-[2.5rem] shadow-2xl overflow-hidden"
|
||||
initial={{ scale: 0.92, opacity: 0, y: 20 }}
|
||||
className="relative z-10 w-full max-w-3xl bg-surface border border-border-soft rounded-[2rem] shadow-2xl overflow-hidden"
|
||||
initial={{ scale: 0.93, opacity: 0, y: 16 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.92, opacity: 0, y: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
exit={{ scale: 0.93, opacity: 0, y: 16 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-5 right-5 p-2 rounded-xl text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors z-10"
|
||||
className="absolute top-4 right-4 p-2 rounded-xl text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors z-10"
|
||||
>
|
||||
<X size={20} />
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin text-brand mb-3" size={36} />
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-brand mb-3" size={32} />
|
||||
<p className="text-text-muted font-bold text-sm">Carregando dados...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle size={36} className="text-rose-500 mb-3" />
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<AlertCircle size={32} className="text-rose-500 mb-3" />
|
||||
<p className="text-text-muted font-bold text-sm">{error}</p>
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={data.userName} photo={data.userPhoto} />
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-text-main">{data.userName}</h2>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-text-muted text-sm font-medium">
|
||||
<Mail size={14} className="text-brand" />
|
||||
<h2 className="text-xl font-black text-text-main">{data.userName}</h2>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-text-muted text-sm">
|
||||
<Mail size={12} className="text-brand" />
|
||||
{data.userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary chips */}
|
||||
{/* 4 stat chips */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
|
||||
<CheckCircle2 size={14} className="text-accent mb-1" />
|
||||
<div className="text-lg font-black text-text-main leading-none">{totalFinished}</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Concluídas</div>
|
||||
</div>
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
|
||||
<Clock size={14} className="text-brand mb-1" />
|
||||
<div className="text-lg font-black text-text-main leading-none">{formatMinutes(totalMinutes)}</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Trabalhado</div>
|
||||
</div>
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
|
||||
<TrendingUp size={14} className="text-cyan-500 mb-1" />
|
||||
<div className="text-lg font-black text-text-main leading-none">{formatMinutes(Math.round(avgPerDay))}</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Média/dia</div>
|
||||
</div>
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
|
||||
<Zap size={14} className="text-amber-500 mb-1" />
|
||||
<div className="text-lg font-black text-text-main leading-none">{bestLabel}</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Melhor período</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar charts side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-app-bg border border-border-soft rounded-2xl p-4 flex flex-col items-center text-center">
|
||||
<CheckCircle2 size={16} className="text-accent mb-1" />
|
||||
<div className="text-xl font-black text-text-main">
|
||||
{data.monthlyStats.reduce((s, m) => s + m.finishedTasksCount, 0)}
|
||||
</div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-text-muted mt-0.5">
|
||||
Concluídas (6m)
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-app-bg border border-border-soft rounded-2xl p-4 flex flex-col items-center text-center">
|
||||
<Clock size={16} className="text-brand mb-1" />
|
||||
<div className="text-xl font-black text-text-main">
|
||||
{formatMinutes(data.monthlyStats.reduce((s, m) => s + m.totalMinutesWorked, 0))}
|
||||
</div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-text-muted mt-0.5">
|
||||
Trabalhado (6m)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart: finished tasks per month */}
|
||||
<div className="bg-app-bg border border-border-soft rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CheckCircle2 size={16} className="text-accent" />
|
||||
<span className="text-sm font-black text-text-main">Tarefas Concluídas por Mês</span>
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<CheckCircle2 size={13} className="text-accent" />
|
||||
<span className="text-xs font-black text-text-main">
|
||||
Tarefas Concluídas {data.groupedBy === "DAY" ? "por Dia" : "por Mês"}
|
||||
</span>
|
||||
</div>
|
||||
<BarChart
|
||||
data={data.monthlyStats}
|
||||
data={data.periodStats}
|
||||
valueKey="finishedTasksCount"
|
||||
color={BAR_COLOR}
|
||||
formatValue={(v) => String(v)}
|
||||
color="#7C3AED"
|
||||
groupedBy={data.groupedBy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart: minutes worked per month */}
|
||||
<div className="bg-app-bg border border-border-soft rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock size={16} className="text-brand" />
|
||||
<span className="text-sm font-black text-text-main">Tempo Trabalhado por Mês</span>
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Clock size={13} className="text-brand" />
|
||||
<span className="text-xs font-black text-text-main">
|
||||
Tempo Trabalhado {data.groupedBy === "DAY" ? "por Dia" : "por Mês"}
|
||||
</span>
|
||||
</div>
|
||||
<BarChart
|
||||
data={data.monthlyStats}
|
||||
data={data.periodStats}
|
||||
valueKey="totalMinutesWorked"
|
||||
color={BAR_COLOR_2}
|
||||
formatValue={formatMinutes}
|
||||
color="#06B6D4"
|
||||
groupedBy={data.groupedBy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pie chart — hours by project */}
|
||||
{data.projectStats.length > 0 && (
|
||||
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<span className="text-xs font-black text-text-main">Horas por Projeto</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-20 h-20 shrink-0">
|
||||
<MiniPieChart data={data.projectStats} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
{data.projectStats
|
||||
.slice()
|
||||
.sort((a, b) => b.totalMinutes - a.totalMinutes)
|
||||
.map((p, i) => {
|
||||
const total = data.projectStats.reduce((s, x) => s + x.totalMinutes, 0);
|
||||
const pct = total > 0 ? ((p.totalMinutes / total) * 100).toFixed(0) : "0";
|
||||
return (
|
||||
<div key={p.projectId} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} />
|
||||
<span className="text-xs font-bold text-text-main truncate flex-1">{p.projectName}</span>
|
||||
<span className="text-[10px] font-black text-text-muted shrink-0">
|
||||
{formatMinutes(p.totalMinutes)} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -424,6 +424,8 @@ export const OrgDashboardPage: React.FC = () => {
|
|||
<MemberDetailModal
|
||||
memberId={selectedMemberId}
|
||||
orgId={activeOrg.id}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onClose={() => setSelectedMemberId(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -27,19 +27,28 @@ export interface OrgDashboardData {
|
|||
memberStats: OrgMemberStats[]
|
||||
}
|
||||
|
||||
export interface MonthlyStats {
|
||||
export interface PeriodStats {
|
||||
year: number
|
||||
month: number
|
||||
day: number | null
|
||||
finishedTasksCount: number
|
||||
totalMinutesWorked: number
|
||||
}
|
||||
|
||||
export interface MemberProjectStats {
|
||||
projectId: number
|
||||
projectName: string
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export interface MemberDetailData {
|
||||
userId: number
|
||||
userName: string
|
||||
userEmail: string
|
||||
userPhoto: string | null
|
||||
monthlyStats: MonthlyStats[]
|
||||
groupedBy: 'DAY' | 'MONTH'
|
||||
periodStats: PeriodStats[]
|
||||
projectStats: MemberProjectStats[]
|
||||
}
|
||||
|
||||
export const orgDashboardService = {
|
||||
|
|
@ -50,7 +59,8 @@ export const orgDashboardService = {
|
|||
return apiFetch<OrgDashboardData>(`/organizations/${orgId}/dashboard?${params.toString()}`)
|
||||
},
|
||||
|
||||
async getMemberStats(orgId: number, memberId: number): Promise<MemberDetailData> {
|
||||
return apiFetch<MemberDetailData>(`/organizations/${orgId}/members/${memberId}/stats`)
|
||||
async getMemberStats(orgId: number, memberId: number, startDate: string, endDate: string): Promise<MemberDetailData> {
|
||||
const params = new URLSearchParams({ startDate, endDate })
|
||||
return apiFetch<MemberDetailData>(`/organizations/${orgId}/members/${memberId}/stats?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue