From 6e77f7b194a763fe7eb7eabf9f2c94e20a309121 Mon Sep 17 00:00:00 2001 From: Gabriel Santana de Menezes Date: Sat, 9 May 2026 21:05:15 -0400 Subject: [PATCH] feat: add hover tooltips to all charts and increase project legend font - PieChart (org dashboard): tooltip on hover shows project name, time and % - BarChart (member detail): tooltip on hover shows period label and value - MiniPieChart (member detail): tooltip on hover shows project name, time and % - Increase project stats legend font from text-[10px] to text-xs (12px) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/MemberDetailModal.tsx | 152 ++++++++++++++---- frontend/src/pages/OrgDashboardPage.tsx | 73 ++++++--- 2 files changed, 178 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/MemberDetailModal.tsx b/frontend/src/components/MemberDetailModal.tsx index 0275030..7eb02c7 100644 --- a/frontend/src/components/MemberDetailModal.tsx +++ b/frontend/src/components/MemberDetailModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle, TrendingUp, Zap } from "lucide-react"; import { format, differenceInDays } from "date-fns"; @@ -27,6 +27,24 @@ function getPeriodLabel(s: PeriodStats, groupedBy: "DAY" | "MONTH"): string { return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`; } +interface TooltipState { x: number; y: number; content: string } + +function ChartTooltip({ tooltip }: { tooltip: TooltipState }) { + const above = tooltip.y > 40; + return ( +
+ {tooltip.content} +
+ ); +} + function Avatar({ name, photo }: { name: string; photo?: string | null }) { if (photo) return {name}; const initials = name.split(" ").slice(0, 2).map((n) => n[0]).join("").toUpperCase(); @@ -42,12 +60,23 @@ function BarChart({ valueKey, color, groupedBy, + formatValue, }: { data: PeriodStats[]; valueKey: "finishedTasksCount" | "totalMinutesWorked"; color: string; groupedBy: "DAY" | "MONTH"; + formatValue: (v: number) => string; }) { + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + + 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 }); + }; + const values = data.map((d) => d[valueKey] as number); const maxVal = Math.max(...values, 1); const chartW = 220; @@ -62,30 +91,74 @@ function BarChart({ }; return ( - - {data.map((d, i) => { - const val = d[valueKey] as number; - const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0; - const x = i * (barW + gap); - const y = barAreaH - barH; - const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : ""; +
+ setTooltip(null)} + > + {data.map((d, i) => { + const val = d[valueKey] as number; + const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0; + const x = i * (barW + gap); + const y = barAreaH - barH; + const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : ""; - return ( - - - {label && ( - - {label} - - )} - - ); - })} - + return ( + + + handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`) + } + /> + {/* invisible hit area for zero-height bars */} + {val === 0 && ( + + handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`) + } + /> + )} + {label && ( + + {label} + + )} + + ); + })} + + {tooltip && } +
); } function MiniPieChart({ data }: { data: MemberProjectStats[] }) { + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + const total = data.reduce((s, d) => s + d.totalMinutes, 0); if (total === 0 || data.length === 0) { return ( @@ -108,17 +181,40 @@ function MiniPieChart({ data }: { data: MemberProjectStats[] }) { const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle); const largeArc = angle > Math.PI ? 1 : 0; const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`; - const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], name: d.projectName, fraction }; + const pct = (fraction * 100).toFixed(0); + const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], 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 ( - - {slices.map((s, i) => ( - - ))} - +
+ setTooltip(null)} + > + {slices.map((s, i) => ( + handleMouseMove(e, s.label)} + /> + ))} + + {tooltip && } +
); } @@ -257,6 +353,7 @@ export const MemberDetailModal: React.FC = ({ valueKey="finishedTasksCount" color="#7C3AED" groupedBy={data.groupedBy} + formatValue={(v) => `${v} tarefa${v !== 1 ? "s" : ""}`} /> @@ -272,6 +369,7 @@ export const MemberDetailModal: React.FC = ({ valueKey="totalMinutesWorked" color="#06B6D4" groupedBy={data.groupedBy} + formatValue={formatMinutes} /> @@ -297,7 +395,7 @@ export const MemberDetailModal: React.FC = ({
{p.projectName} - + {formatMinutes(p.totalMinutes)} ({pct}%)
diff --git a/frontend/src/pages/OrgDashboardPage.tsx b/frontend/src/pages/OrgDashboardPage.tsx index a6016c6..bc3f9d6 100644 --- a/frontend/src/pages/OrgDashboardPage.tsx +++ b/frontend/src/pages/OrgDashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useRef, useState, useCallback } from "react"; import { MemberDetailModal } from "../components/MemberDetailModal"; import { motion } from "framer-motion"; import { @@ -35,7 +35,12 @@ const CHART_COLORS = [ "#EC4899", ]; +interface TooltipState { x: number; y: number; content: string } + function PieChart({ data }: { data: OrgProjectStats[] }) { + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + const total = data.reduce((sum, d) => sum + d.totalMinutes, 0); if (total === 0 || data.length === 0) { return ( @@ -76,30 +81,58 @@ function PieChart({ data }: { data: OrgProjectStats[] }) { "Z", ].join(" "); - const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], fraction, name: d.projectName }; + 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 ( - - {slices.map((slice, i) => ( - - ))} - - projetos - - - {data.length} - - +
+ setTooltip(null)} + > + {slices.map((slice, i) => ( + handleMouseMove(e, slice.label)} + /> + ))} + + projetos + + + {data.length} + + + {tooltip && ( +
+ {tooltip.content} +
+ )} +
); }