feat: dashboard da organização para admin/owner
Adiciona tela de analytics organizacional acessível apenas por OWNER e ADMIN, com gráfico de pizza por projeto, top 5 tarefas e ranking de membros por tempo trabalhado. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89b28ea981
commit
faf4bed682
14 changed files with 727 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,4 +63,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
public List<Task> findTasksByUser(String email) {
|
||||
return find("user.email = ?1", email).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Task> 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<Task> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package net.beetechgroup.beetask.usecase.orgdashboard;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record OrgDashboardOutput(
|
||||
List<OrgProjectStats> projectStats,
|
||||
List<OrgTopTask> topTasks,
|
||||
List<OrgMemberStats> memberStats
|
||||
) {}
|
||||
|
|
@ -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<Task> workedTasks = taskRepository.findTasksWorkedByOrgInPeriod(
|
||||
input.organizationId(), input.startDate(), input.endDate()
|
||||
);
|
||||
List<Task> finishedTasks = taskRepository.findTasksFinishedByOrgInPeriod(
|
||||
input.organizationId(), input.startDate(), input.endDate()
|
||||
);
|
||||
|
||||
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
|
||||
Map<String, Long> 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<OrgTopTask> 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<String, Long> finishedByMember = new HashMap<>();
|
||||
for (Task task : finishedTasks) {
|
||||
if (task.getUser() != null) {
|
||||
finishedByMember.merge(task.getUser().getEmail(), 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
List<UserOrganization> members = userOrganizationRepository.findByOrganizationIdAndStatus(
|
||||
input.organizationId(), UserOrganizationStatus.ACTIVE
|
||||
);
|
||||
|
||||
List<OrgMemberStats> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package net.beetechgroup.beetask.usecase.orgdashboard;
|
||||
|
||||
public record OrgMemberStats(
|
||||
Long userId,
|
||||
String userName,
|
||||
String userPhoto,
|
||||
long finishedTasksCount,
|
||||
long totalMinutesWorked
|
||||
) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.usecase.orgdashboard;
|
||||
|
||||
public record OrgProjectStats(Long projectId, String projectName, long totalMinutes) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.usecase.orgdashboard;
|
||||
|
||||
public record OrgTopTask(Long taskId, String title, String projectName, long totalMinutes) {}
|
||||
|
|
@ -12,4 +12,6 @@ public interface TaskRepository {
|
|||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksByUser(String email);
|
||||
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</NavLink>
|
||||
)}
|
||||
|
||||
{(activeOrg?.roles.includes("OWNER") ||
|
||||
activeOrg?.roles.includes("ADMIN")) && (
|
||||
<NavLink to="/org-dashboard" className={navLinkClassName}>
|
||||
<PieChart size={18} />
|
||||
Org Dashboard
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Organizações Dropdown */}
|
||||
<div className="relative">
|
||||
<NavLink
|
||||
|
|
@ -436,6 +445,19 @@ export function AppShell() {
|
|||
</NavLink>
|
||||
)}
|
||||
|
||||
{(activeOrg?.roles.includes("OWNER") ||
|
||||
activeOrg?.roles.includes("ADMIN")) && (
|
||||
<NavLink
|
||||
to="/org-dashboard"
|
||||
className={({ isActive }) =>
|
||||
cn(navLinkClassName({ isActive }), "flex-col gap-0.5 py-1")
|
||||
}
|
||||
>
|
||||
<PieChart size={20} />
|
||||
<span className="text-[10px]">Org</span>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Mobile Organizações Group */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
|
|
|||
421
frontend/src/pages/OrgDashboardPage.tsx
Normal file
421
frontend/src/pages/OrgDashboardPage.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Trophy,
|
||||
Users,
|
||||
Briefcase,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Medal,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { format, startOfMonth, endOfMonth } from "date-fns";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
orgDashboardService,
|
||||
type OrgDashboardData,
|
||||
type OrgProjectStats,
|
||||
} from "../services/orgDashboardService";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const CHART_COLORS = [
|
||||
"#7C3AED",
|
||||
"#06B6D4",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#3B82F6",
|
||||
"#EC4899",
|
||||
];
|
||||
|
||||
function PieChart({ data }: { data: OrgProjectStats[] }) {
|
||||
const total = data.reduce((sum, d) => sum + d.totalMinutes, 0);
|
||||
if (total === 0 || data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full opacity-40 font-bold text-sm">
|
||||
Sem dados no período
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cx = 100;
|
||||
const cy = 100;
|
||||
const r = 70;
|
||||
const innerR = 42;
|
||||
|
||||
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);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
const ix1 = cx + innerR * Math.cos(endAngle);
|
||||
const iy1 = cy + innerR * Math.sin(endAngle);
|
||||
const ix2 = cx + innerR * Math.cos(startAngle);
|
||||
const 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",
|
||||
].join(" ");
|
||||
|
||||
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], fraction, name: d.projectName };
|
||||
startAngle = endAngle;
|
||||
return result;
|
||||
});
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 200 200" className="w-full h-full">
|
||||
{slices.map((slice, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={slice.path}
|
||||
fill={slice.color}
|
||||
stroke="var(--color-surface, #fff)"
|
||||
strokeWidth="2"
|
||||
className="transition-opacity hover:opacity-80"
|
||||
/>
|
||||
))}
|
||||
<text x={cx} y={cy - 8} textAnchor="middle" className="text-xs" fontSize="11" fill="currentColor" opacity="0.5">
|
||||
projetos
|
||||
</text>
|
||||
<text x={cx} y={cy + 10} textAnchor="middle" fontSize="20" fontWeight="900" fill="currentColor">
|
||||
{data.length}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 Avatar({ name, photo, size = "md" }: { name: string; photo?: string | null; size?: "sm" | "md" | "lg" }) {
|
||||
const sizeClass = size === "lg" ? "w-16 h-16 text-xl" : size === "md" ? "w-12 h-12 text-base" : "w-9 h-9 text-sm";
|
||||
if (photo) {
|
||||
return <img src={photo} alt={name} className={cn("rounded-2xl object-cover shrink-0", sizeClass)} />;
|
||||
}
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return (
|
||||
<div className={cn("rounded-2xl bg-brand/10 text-brand font-black flex items-center justify-center shrink-0", sizeClass)}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const OrgDashboardPage: React.FC = () => {
|
||||
const { activeOrg } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [data, setData] = useState<OrgDashboardData | null>(null);
|
||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||
const [endDate, setEndDate] = useState(endOfMonth(new Date()));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!activeOrg) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await orgDashboardService.getDashboard(
|
||||
activeOrg.id,
|
||||
format(startDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
format(endDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
);
|
||||
setData(result);
|
||||
} catch {
|
||||
setError("Falha ao carregar os dados do dashboard da organização.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeOrg, startDate, endDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const totalMinutes = data?.projectStats.reduce((sum, p) => sum + p.totalMinutes, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-20">
|
||||
{/* Header & Filters */}
|
||||
<header className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-brand font-black uppercase tracking-widest text-[10px] mb-2">
|
||||
<BarChart3 size={14} />
|
||||
Visão da Organização
|
||||
</div>
|
||||
<h1 className="text-4xl font-black tracking-tight text-text-main">
|
||||
Dashboard da Org
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-text-muted">
|
||||
{activeOrg?.name} — desempenho e produtividade do time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 bg-surface border border-border-soft p-2 rounded-2xl shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-r border-border-soft">
|
||||
<Calendar size={18} className="text-brand" />
|
||||
<input
|
||||
type="date"
|
||||
value={format(startDate, "yyyy-MM-dd")}
|
||||
onChange={(e) => setStartDate(new Date(e.target.value + "T00:00:00"))}
|
||||
className="bg-transparent border-none text-sm font-bold text-text-main focus:ring-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<input
|
||||
type="date"
|
||||
value={format(endDate, "yyyy-MM-dd")}
|
||||
onChange={(e) => setEndDate(new Date(e.target.value + "T23:59:59"))}
|
||||
className="bg-transparent border-none text-sm font-bold text-text-main focus:ring-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="p-2 bg-brand text-white rounded-xl hover:scale-105 transition-transform active:scale-95"
|
||||
>
|
||||
<Filter size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="animate-spin text-brand mb-4" size={48} />
|
||||
<p className="text-text-muted font-bold">Carregando dados da organização...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center p-6 bg-surface rounded-[2.5rem] border border-border-soft">
|
||||
<AlertCircle size={48} className="text-rose-500 mb-4" />
|
||||
<h3 className="text-xl font-bold text-text-main mb-2">Ocorreu um erro</h3>
|
||||
<p className="text-text-muted max-w-sm mb-6">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="px-6 py-3 bg-brand text-white rounded-xl font-bold hover:bg-brand-dark transition-all"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Pie chart + Top Tasks */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Pie Chart — Projects */}
|
||||
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Briefcase size={20} className="text-brand" />
|
||||
<h2 className="text-xl font-black text-text-main">Tempo por Projeto</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-8">
|
||||
<div className="w-48 h-48 shrink-0">
|
||||
<PieChart data={data.projectStats} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 w-full">
|
||||
{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 (
|
||||
<div key={stat.projectId} className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ background: CHART_COLORS[i % CHART_COLORS.length] }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-bold text-text-main truncate">{stat.projectName}</span>
|
||||
<span className="text-xs font-black text-text-muted ml-2 shrink-0">
|
||||
{formatMinutes(stat.totalMinutes)} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-surface-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
className="h-full rounded-full"
|
||||
style={{ background: CHART_COLORS[i % CHART_COLORS.length] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-center opacity-40 font-bold text-sm py-6">
|
||||
Nenhum projeto com atividade no período.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Top 5 Tasks */}
|
||||
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Trophy size={20} className="text-amber-500" />
|
||||
<h2 className="text-xl font-black text-text-main">Top 5 Tarefas</h2>
|
||||
<span className="ml-auto text-xs font-bold text-text-muted bg-surface-muted px-2 py-1 rounded-lg">
|
||||
por tempo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.topTasks.length > 0 ? (
|
||||
data.topTasks.map((task, idx) => (
|
||||
<motion.div
|
||||
key={task.taskId}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.07 }}
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-2xl border transition-all",
|
||||
idx === 0
|
||||
? "border-amber-400/40 bg-amber-500/5"
|
||||
: "border-border-soft bg-app-bg",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-xl flex items-center justify-center font-black text-sm shrink-0",
|
||||
idx === 0
|
||||
? "bg-amber-500 text-white shadow-lg shadow-amber-500/30"
|
||||
: idx === 1
|
||||
? "bg-slate-300 text-slate-700"
|
||||
: idx === 2
|
||||
? "bg-orange-300 text-orange-800"
|
||||
: "bg-surface-muted text-text-muted",
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-text-main truncate">{task.title}</div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-brand mt-0.5">
|
||||
{task.projectName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-black text-text-muted shrink-0">
|
||||
<Timer size={14} className="text-brand" />
|
||||
{formatMinutes(task.totalMinutes)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center opacity-40 font-bold text-sm py-10">
|
||||
Nenhuma tarefa com atividade no período.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Members Section */}
|
||||
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<Users size={20} className="text-brand" />
|
||||
<h2 className="text-xl font-black text-text-main">Membros do Time</h2>
|
||||
<span className="ml-auto text-xs font-bold text-text-muted bg-surface-muted px-2 py-1 rounded-lg">
|
||||
ordenado por tempo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.memberStats.length === 0 ? (
|
||||
<p className="text-center opacity-40 font-bold py-10">
|
||||
Nenhum membro encontrado.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{data.memberStats.map((member, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<motion.div
|
||||
key={member.userId}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.06 }}
|
||||
className={cn(
|
||||
"relative rounded-[1.5rem] p-6 border transition-all flex flex-col items-center text-center gap-3",
|
||||
isFirst
|
||||
? "border-amber-400/50 bg-gradient-to-b from-amber-500/10 to-amber-500/5 shadow-xl shadow-amber-500/10"
|
||||
: "border-border-soft bg-app-bg hover:border-brand/30 hover:shadow-md",
|
||||
)}
|
||||
>
|
||||
{isFirst && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 flex items-center gap-1 bg-amber-500 text-white text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-lg shadow-amber-500/30">
|
||||
<Medal size={10} />
|
||||
Top 1
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Avatar name={member.userName} photo={member.userPhoto} size={isFirst ? "lg" : "md"} />
|
||||
|
||||
<div>
|
||||
<div className={cn("font-black text-text-main", isFirst ? "text-lg" : "text-base")}>
|
||||
{member.userName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-2 gap-2 mt-1">
|
||||
<div className="bg-surface rounded-xl p-3 border border-border-soft">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Clock size={12} className="text-brand" />
|
||||
</div>
|
||||
<div className={cn("font-black text-text-main", isFirst ? "text-lg" : "text-base")}>
|
||||
{formatMinutes(member.totalMinutesWorked)}
|
||||
</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-0.5">
|
||||
Trabalhado
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-3 border border-border-soft">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<CheckCircle2 size={12} className="text-accent" />
|
||||
</div>
|
||||
<div className={cn("font-black text-text-main", isFirst ? "text-lg" : "text-base")}>
|
||||
{member.finishedTasksCount}
|
||||
</div>
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-0.5">
|
||||
Concluídas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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: <DashboardPage />,
|
||||
},
|
||||
{
|
||||
path: "org-dashboard",
|
||||
element: (
|
||||
<RoleGate allowedRoles={["OWNER", "ADMIN"]} message="Apenas administradores e proprietários podem acessar o dashboard da organização.">
|
||||
<OrgDashboardPage />
|
||||
</RoleGate>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
37
frontend/src/services/orgDashboardService.ts
Normal file
37
frontend/src/services/orgDashboardService.ts
Normal file
|
|
@ -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<OrgDashboardData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('startDate', startDate)
|
||||
if (endDate) params.append('endDate', endDate)
|
||||
return apiFetch<OrgDashboardData>(`/organizations/${orgId}/dashboard?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue