diff --git a/frontend/src/components/DashboardCharts.tsx b/frontend/src/components/DashboardCharts.tsx new file mode 100644 index 0000000..935df49 --- /dev/null +++ b/frontend/src/components/DashboardCharts.tsx @@ -0,0 +1,193 @@ +import React, { useRef, useState } from "react"; + +export const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]; +export const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"]; + +export interface BarDataPoint { + year: number; + month: number; + day: number | null; + finishedTasksCount: number; + totalMinutesWorked: number; +} + +export interface ProjectSlice { + projectName: string; + totalMinutes: number; +} + +export function chartFormatMinutes(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} + +export function getPeriodLabel(s: BarDataPoint, groupedBy: "DAY" | "MONTH"): string { + if (groupedBy === "DAY") { + return `${String(s.day).padStart(2, "0")}/${String(s.month).padStart(2, "0")}`; + } + return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`; +} + +export interface TooltipState { x: number; y: number; content: string } + +export function ChartTooltip({ tooltip }: { tooltip: TooltipState }) { + const above = tooltip.y > 40; + return ( +
+ {tooltip.content} +
+ ); +} + +export function BarChart({ + data, + valueKey, + color, + groupedBy, + formatValue, +}: { + data: BarDataPoint[]; + 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; + const barAreaH = 80; + const barCount = data.length; + const gap = barCount > 15 ? 2 : 4; + const barW = barCount > 0 ? Math.max(2, Math.floor((chartW - gap * (barCount - 1)) / barCount)) : 10; + + const showLabel = (i: number) => { + if (groupedBy === "DAY") return barCount <= 10 || i % Math.ceil(barCount / 6) === 0; + return true; + }; + + return ( +
+ 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 ( + + handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)} + /> + {val === 0 && ( + handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)} + /> + )} + {label && ( + + {label} + + )} + + ); + })} + + {tooltip && } +
+ ); +} + +export function ProjectMiniPieChart({ data }: { data: ProjectSlice[] }) { + 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 ( +
+ Sem dados +
+ ); + } + + const cx = 50, cy = 50, r = 38, innerR = 22; + 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), y1 = cy + r * Math.sin(startAngle); + const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle); + const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle); + 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 pct = (fraction * 100).toFixed(0); + const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], label: `${d.projectName}: ${chartFormatMinutes(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 ( +
+ setTooltip(null)} + > + {slices.map((s, i) => ( + handleMouseMove(e, s.label)} + /> + ))} + + {tooltip && } +
+ ); +} diff --git a/frontend/src/components/MemberDetailModal.tsx b/frontend/src/components/MemberDetailModal.tsx index 6907b52..f91ddb8 100644 --- a/frontend/src/components/MemberDetailModal.tsx +++ b/frontend/src/components/MemberDetailModal.tsx @@ -6,46 +6,18 @@ import { orgDashboardService, type MemberDetailData, type PeriodStats, - type MemberProjectStats, type MemberCategoryStats, } from "../services/orgDashboardService"; import { CategoryIcon } from "./CategoryIcon"; - -const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]; -const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"]; - -function formatMinutes(minutes: number) { - const h = Math.floor(minutes / 60); - const m = minutes % 60; - if (h === 0) return `${m}m`; - if (m === 0) return `${h}h`; - return `${h}h ${m}m`; -} - -function getPeriodLabel(s: PeriodStats, groupedBy: "DAY" | "MONTH"): string { - if (groupedBy === "DAY") { - return `${String(s.day).padStart(2, "0")}/${String(s.month).padStart(2, "0")}`; - } - 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} -
- ); -} +import { + CHART_COLORS, + BarChart, + ProjectMiniPieChart, + ChartTooltip, + chartFormatMinutes, + getPeriodLabel, +} from "./DashboardCharts"; +import type { TooltipState } from "./DashboardCharts"; function Avatar({ name, photo }: { name: string; photo?: string | null }) { if (photo) return {name}; @@ -57,105 +29,6 @@ function Avatar({ name, photo }: { name: string; photo?: string | null }) { ); } -function BarChart({ - data, - 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; - const barAreaH = 80; - const barCount = data.length; - const gap = barCount > 15 ? 2 : 4; - const barW = barCount > 0 ? Math.max(2, Math.floor((chartW - gap * (barCount - 1)) / barCount)) : 10; - - const showLabel = (i: number) => { - if (groupedBy === "DAY") return barCount <= 10 || i % Math.ceil(barCount / 6) === 0; - return true; - }; - - return ( -
- 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 ( - - - 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 CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) { const [tooltip, setTooltip] = useState(null); @@ -184,7 +57,7 @@ function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) { 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 pct = (fraction * 100).toFixed(0); - const result = { path, color: d.color, label: `${d.categoryName}: ${formatMinutes(d.totalMinutes)} (${pct}%)` }; + const result = { path, color: d.color, label: `${d.categoryName}: ${chartFormatMinutes(d.totalMinutes)} (${pct}%)` }; startAngle = endAngle; return result; }); @@ -220,68 +93,6 @@ function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) { ); } -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 ( -
- Sem dados -
- ); - } - - const cx = 50, cy = 50, r = 38, innerR = 22; - 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), y1 = cy + r * Math.sin(startAngle); - const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle); - const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle); - 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 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 ( -
- setTooltip(null)} - > - {slices.map((s, i) => ( - handleMouseMove(e, s.label)} - /> - ))} - - {tooltip && } -
- ); -} interface MemberDetailModalProps { memberId: number | null; @@ -389,12 +200,12 @@ export const MemberDetailModal: React.FC = ({
-
{formatMinutes(totalMinutes)}
+
{chartFormatMinutes(totalMinutes)}
Trabalhado
-
{formatMinutes(Math.round(avgPerDay))}
+
{chartFormatMinutes(Math.round(avgPerDay))}
Média/dia
@@ -434,7 +245,7 @@ export const MemberDetailModal: React.FC = ({ valueKey="totalMinutesWorked" color="#06B6D4" groupedBy={data.groupedBy} - formatValue={formatMinutes} + formatValue={chartFormatMinutes} />
@@ -466,7 +277,7 @@ export const MemberDetailModal: React.FC = ({ {c.categoryName} - {formatMinutes(c.totalMinutes)} ({pct}%) + {chartFormatMinutes(c.totalMinutes)} ({pct}%) ); @@ -484,7 +295,7 @@ export const MemberDetailModal: React.FC = ({
- +
{data.projectStats @@ -498,7 +309,7 @@ export const MemberDetailModal: React.FC = ({
{p.projectName} - {formatMinutes(p.totalMinutes)} ({pct}%) + {chartFormatMinutes(p.totalMinutes)} ({pct}%)
);