feat: add member detail modal on org dashboard
Closes #5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
397e84ae17
commit
90bd08ba39
13 changed files with 499 additions and 2 deletions
|
|
@ -4,6 +4,7 @@ import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
import net.beetechgroup.beetask.usecase.dashboard.DashboardUseCase;
|
import net.beetechgroup.beetask.usecase.dashboard.DashboardUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.orgdashboard.OrgDashboardUseCase;
|
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.organization.auth.AuthorizeOrganizationAdminUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||||
|
|
@ -22,4 +23,11 @@ public class DashboardUseCaseConfig {
|
||||||
AuthorizeOrganizationAdminUseCase authorizer) {
|
AuthorizeOrganizationAdminUseCase authorizer) {
|
||||||
return new OrgDashboardUseCase(taskRepository, userOrganizationRepository, authorizer);
|
return new OrgDashboardUseCase(taskRepository, userOrganizationRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public MemberDetailUseCase memberDetailUseCase(TaskRepository taskRepository,
|
||||||
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
AuthorizeOrganizationAdminUseCase authorizer) {
|
||||||
|
return new MemberDetailUseCase(taskRepository, userOrganizationRepository, authorizer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,22 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||||
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> 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<Task> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<MonthlyStatsResponse> monthlyStats
|
||||||
|
) {
|
||||||
|
public record MonthlyStatsResponse(int year, int month, long finishedTasksCount, long totalMinutesWorked) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.orgdashboard.memberdetail;
|
||||||
|
|
||||||
|
public record MemberDetailInput(String userEmail, Long organizationId, Long memberId) {
|
||||||
|
}
|
||||||
|
|
@ -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<MemberDetailOutput.MonthlyStats> monthlyStats) {
|
||||||
|
return new MemberDetailOutput(
|
||||||
|
member.getUser().getId(),
|
||||||
|
member.getUser().getName(),
|
||||||
|
member.getUser().getEmail(),
|
||||||
|
member.getUser().getPhoto(),
|
||||||
|
monthlyStats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> monthlyStats
|
||||||
|
) {
|
||||||
|
public record MonthlyStats(int year, int month, long finishedTasksCount, long totalMinutesWorked) {}
|
||||||
|
}
|
||||||
|
|
@ -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<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end);
|
||||||
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
|
||||||
|
|
||||||
|
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();
|
||||||
|
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<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(),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,4 +14,6 @@ public interface TaskRepository {
|
||||||
List<Task> findTasksByUser(String email);
|
List<Task> findTasksByUser(String email);
|
||||||
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
}
|
}
|
||||||
243
frontend/src/components/MemberDetailModal.tsx
Normal file
243
frontend/src/components/MemberDetailModal.tsx
Normal file
|
|
@ -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 <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<svg viewBox={`0 0 ${chartW} ${chartH + 20}`} className="w-full">
|
||||||
|
{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 (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberDetailModalProps {
|
||||||
|
memberId: number | null;
|
||||||
|
orgId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({ memberId, orgId, 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;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{memberId !== null && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
<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 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.92, opacity: 0, y: 20 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-brand mb-3" size={36} />
|
||||||
|
<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" />
|
||||||
|
<p className="text-text-muted font-bold text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<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" />
|
||||||
|
{data.userEmail}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary chips */}
|
||||||
|
<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>
|
||||||
|
<BarChart
|
||||||
|
data={data.monthlyStats}
|
||||||
|
valueKey="finishedTasksCount"
|
||||||
|
color={BAR_COLOR}
|
||||||
|
formatValue={(v) => String(v)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<BarChart
|
||||||
|
data={data.monthlyStats}
|
||||||
|
valueKey="totalMinutesWorked"
|
||||||
|
color={BAR_COLOR_2}
|
||||||
|
formatValue={formatMinutes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { MemberDetailModal } from "../components/MemberDetailModal";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
|
@ -134,6 +135,7 @@ export const OrgDashboardPage: React.FC = () => {
|
||||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||||
const [endDate, setEndDate] = useState(endOfMonth(new Date()));
|
const [endDate, setEndDate] = useState(endOfMonth(new Date()));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedMemberId, setSelectedMemberId] = useState<number | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
if (!activeOrg) return;
|
if (!activeOrg) return;
|
||||||
|
|
@ -361,10 +363,11 @@ export const OrgDashboardPage: React.FC = () => {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: idx * 0.06 }}
|
transition={{ delay: idx * 0.06 }}
|
||||||
|
onClick={() => setSelectedMemberId(member.userId)}
|
||||||
className={cn(
|
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
|
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",
|
: "border-border-soft bg-app-bg hover:border-brand/30 hover:shadow-md",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -416,6 +419,14 @@ export const OrgDashboardPage: React.FC = () => {
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeOrg && (
|
||||||
|
<MemberDetailModal
|
||||||
|
memberId={selectedMemberId}
|
||||||
|
orgId={activeOrg.id}
|
||||||
|
onClose={() => setSelectedMemberId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,30 @@ export interface OrgDashboardData {
|
||||||
memberStats: OrgMemberStats[]
|
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 = {
|
export const orgDashboardService = {
|
||||||
async getDashboard(orgId: number, startDate?: string, endDate?: string): Promise<OrgDashboardData> {
|
async getDashboard(orgId: number, startDate?: string, endDate?: string): Promise<OrgDashboardData> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (startDate) params.append('startDate', startDate)
|
if (startDate) params.append('startDate', startDate)
|
||||||
if (endDate) params.append('endDate', endDate)
|
if (endDate) params.append('endDate', endDate)
|
||||||
return apiFetch<OrgDashboardData>(`/organizations/${orgId}/dashboard?${params.toString()}`)
|
return apiFetch<OrgDashboardData>(`/organizations/${orgId}/dashboard?${params.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMemberStats(orgId: number, memberId: number): Promise<MemberDetailData> {
|
||||||
|
return apiFetch<MemberDetailData>(`/organizations/${orgId}/members/${memberId}/stats`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue