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:
Gabriel Santana de Menezes 2026-05-09 14:22:28 -04:00
parent 89b28ea981
commit faf4bed682
14 changed files with 727 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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));
}
}

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -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();
}
}

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.orgdashboard;
public record OrgMemberStats(
Long userId,
String userName,
String userPhoto,
long finishedTasksCount,
long totalMinutesWorked
) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.orgdashboard;
public record OrgProjectStats(Long projectId, String projectName, long totalMinutes) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.orgdashboard;
public record OrgTopTask(Long taskId, String title, String projectName, long totalMinutes) {}

View file

@ -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);
}

View file

@ -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

View 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>
);
};

View file

@ -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>
),
},
],
},
]);

View 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()}`)
}
}