beetask/frontend/src/pages/OrgDashboardPage.tsx
Gabriel Santana de Menezes 2fdb375462 feat: adicionar categorias para tasks (#23)
Adiciona conceito de Categoria por organização (nome, cor e ícone lucide),
com CRUD restrito a OWNER/ADMIN. Tasks ganham categoria opcional, filtros
multiselect de status e categoria nas listagens, badges nos cards, e
gráficos de pizza de tempo por categoria nos dashboards pessoal, da
organização e no modal de detalhes do membro.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:18:41 -04:00

531 lines
22 KiB
TypeScript

import React, { useEffect, useRef, useState, useCallback } from "react";
import { MemberDetailModal } from "../components/MemberDetailModal";
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";
import { PieChart as SharedPieChart } from "../components/PieChart";
import { CategoryIcon } from "../components/CategoryIcon";
import { Tag } from "lucide-react";
const CHART_COLORS = [
"#7C3AED",
"#06B6D4",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#3B82F6",
"#EC4899",
];
interface TooltipState { x: number; y: number; content: string }
function PieChart({ data }: { data: OrgProjectStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
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 pct = ((fraction) * 100).toFixed(1);
const result = {
path,
color: CHART_COLORS[i % CHART_COLORS.length],
fraction,
label: `${d.projectName}: ${formatMinutes(d.totalMinutes)} (${pct}%)`,
};
startAngle = endAngle;
return result;
});
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return (
<div className="relative w-full h-full">
<svg
ref={svgRef}
viewBox="0 0 200 200"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{slices.map((slice, i) => (
<path
key={i}
d={slice.path}
fill={slice.color}
stroke="var(--color-surface, #fff)"
strokeWidth="2"
className="cursor-pointer transition-opacity hover:opacity-80"
onMouseMove={(e) => handleMouseMove(e, slice.label)}
/>
))}
<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>
{tooltip && (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 36, transform: "translateX(-50%)" }}
>
{tooltip.content}
</div>
)}
</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 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 [selectedMemberId, setSelectedMemberId] = useState<number | 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 ?? "geral"} 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 3 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>
{/* Category Pie */}
{data.categoryStats.length > 0 && (
<section className="bg-surface border border-border-soft rounded-[2.5rem] p-8 shadow-sm">
<div className="flex items-center gap-2 mb-8">
<Tag size={20} className="text-brand" />
<h2 className="text-xl font-black text-text-main">Tarefas por Categoria</h2>
</div>
<div className="flex flex-col sm:flex-row items-center gap-8">
<SharedPieChart
size={200}
centerLabel="categorias"
centerValue={data.categoryStats.length}
slices={data.categoryStats.map((c) => ({
key: c.categoryId,
label: c.categoryName,
value: c.totalMinutes,
color: c.color,
}))}
formatTooltip={(s, pct) => `${s.label}: ${formatMinutes(s.value)} (${pct.toFixed(1)}%)`}
/>
<div className="flex-1 space-y-3 w-full">
{data.categoryStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((c) => {
const totalCat = data.categoryStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = totalCat > 0 ? ((c.totalMinutes / totalCat) * 100).toFixed(1) : "0";
return (
<div key={c.categoryId} className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shrink-0"
style={{ background: `${c.color}1a`, color: c.color }}
>
<CategoryIcon iconName={c.icon} color={c.color} size={18} />
</div>
<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">{c.categoryName}</span>
<span className="text-xs font-black text-text-muted ml-2 shrink-0">
{formatMinutes(c.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: c.color }}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
)}
{/* 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 }}
onClick={() => setSelectedMemberId(member.userId)}
className={cn(
"relative rounded-[1.5rem] p-6 border transition-all flex flex-col items-center text-center gap-3 cursor-pointer",
isFirst
? "border-amber-400/50 bg-gradient-to-b from-amber-500/10 to-amber-500/5 shadow-xl shadow-amber-500/10 hover:shadow-2xl"
: "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}
{activeOrg && (
<MemberDetailModal
memberId={selectedMemberId}
orgId={activeOrg.id}
startDate={startDate}
endDate={endDate}
onClose={() => setSelectedMemberId(null)}
/>
)}
</div>
);
};