From 4dda0e04452de20a0d13f64a6c7e8ff6ec31a851 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Mon, 2 Nov 2026 12:58:34 -0400 Subject: [PATCH] feat(dashboard): add interactive category worked time pie chart to dashboard and bi v2 --- .../components/dashboard/BiUniformCard.tsx | 62 ++++ .../dashboard/CategoryTimePieChart.tsx | 320 ++++++++++++++++++ .../dashboard/CreateBiCardModal.tsx | 7 + frontend/src/lib/userPreferences.ts | 1 + frontend/src/pages/DashboardPage.tsx | 52 ++- 5 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/dashboard/CategoryTimePieChart.tsx diff --git a/frontend/src/components/dashboard/BiUniformCard.tsx b/frontend/src/components/dashboard/BiUniformCard.tsx index 5e0f6d2..be080ee 100644 --- a/frontend/src/components/dashboard/BiUniformCard.tsx +++ b/frontend/src/components/dashboard/BiUniformCard.tsx @@ -18,6 +18,7 @@ import { GripVertical, User, Users, + Tag, } from 'lucide-react' import type { DashboardV2Card } from '../../lib/userPreferences' import type { Task, Project, Category } from '../../types/task' @@ -365,6 +366,65 @@ export const BiUniformCard: React.FC = ({ ) } + case 'CATEGORY_BREAKDOWN': { + const catMap = new Map() + let total = 0 + filteredTasks.forEach((t) => { + const sec = calculateTaskSecondsInRange( + t, + currentRange.startDate, + currentRange.endDate, + activeTask?.id, + activeSeconds + ) + if (sec <= 0) return + total += sec + const name = t.category?.name || 'Sem Categoria' + const color = t.category?.color || '#d97706' + const curr = catMap.get(name) || { name, color, sec: 0 } + curr.sec += sec + catMap.set(name, curr) + }) + + const topCats = Array.from(catMap.values()) + .sort((a, b) => b.sec - a.sec) + .slice(0, 2) + + return ( +
+ {topCats.length === 0 ? ( +

Nenhuma atividade por categoria.

+ ) : ( + topCats.map((c) => { + const pct = total > 0 ? Math.round((c.sec / total) * 100) : 0 + return ( +
+
+ + + {c.name} + + + {formatSecondsToTimer(c.sec)} ({pct}%) + +
+
+
+
+
+ ) + }) + )} +
+ ) + } + case 'WORK_GOAL': { const targetSeconds = (workGoal?.targetHours || 8) * 3600 const workedSeconds = calculateUserPeriodWorkedSeconds( @@ -492,6 +552,8 @@ export const BiUniformCard: React.FC = ({ return PieChart case 'PROJECT_BREAKDOWN': return BarChart3 + case 'CATEGORY_BREAKDOWN': + return Tag case 'WORK_GOAL': return Target case 'ACTIVE_TIMER': diff --git a/frontend/src/components/dashboard/CategoryTimePieChart.tsx b/frontend/src/components/dashboard/CategoryTimePieChart.tsx new file mode 100644 index 0000000..a579020 --- /dev/null +++ b/frontend/src/components/dashboard/CategoryTimePieChart.tsx @@ -0,0 +1,320 @@ +import React, { useState, useMemo } from 'react' +import { Tag, Clock, PieChart, Sparkles } from 'lucide-react' +import type { Task, Category } from '../../types/task' +import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../../lib/timeUtils' +import { calculateTaskSecondsInRange } from '../../lib/periodUtils' + +export interface CategoryTimeData { + id: number | 'uncategorized' + name: string + color: string + seconds: number + percentage: number + taskCount: number +} + +interface CategoryTimePieChartProps { + tasks: Task[] + categories?: Category[] + startDate: Date + endDate: Date + isAllPeriod?: boolean + activeTaskId?: number + activeSeconds?: number + viewScope: 'mine' | 'team' + periodLabel: string +} + +const DEFAULT_CATEGORY_COLORS = [ + '#0f766e', // Teal / Brand + '#f59e0b', // Amber + '#3b82f6', // Blue + '#8b5cf6', // Violet + '#ec4899', // Pink + '#10b981', // Emerald + '#f97316', // Orange + '#06b6d4', // Cyan + '#a855f7', // Purple + '#64748b', // Slate +] + +export const CategoryTimePieChart: React.FC = ({ + tasks, + startDate, + endDate, + isAllPeriod = false, + activeTaskId, + activeSeconds, + viewScope, + periodLabel, +}) => { + const [hoveredId, setHoveredId] = useState(null) + + // Aggregate worked time by category for the given tasks & period + const { categoryList, totalSeconds } = useMemo(() => { + const catMap = new Map< + number | 'uncategorized', + { id: number | 'uncategorized'; name: string; color: string; seconds: number; taskCount: number } + >() + + let total = 0 + let colorIdx = 0 + + for (const t of tasks) { + const sec = isAllPeriod + ? calculateTaskTotalSeconds(t) + : calculateTaskSecondsInRange(t, startDate, endDate, activeTaskId, activeSeconds) + + if (sec <= 0) continue + + total += sec + + const catId = t.category?.id ?? 'uncategorized' + const catName = t.category?.name ?? 'Sem Categoria' + const catColor = + t.category?.color || + (catId === 'uncategorized' + ? '#64748b' + : DEFAULT_CATEGORY_COLORS[colorIdx++ % DEFAULT_CATEGORY_COLORS.length]) + + const existing = catMap.get(catId) + if (existing) { + existing.seconds += sec + existing.taskCount += 1 + } else { + catMap.set(catId, { + id: catId, + name: catName, + color: catColor, + seconds: sec, + taskCount: 1, + }) + } + } + + const list: CategoryTimeData[] = Array.from(catMap.values()) + .map((item) => ({ + ...item, + percentage: total > 0 ? (item.seconds / total) * 100 : 0, + })) + .sort((a, b) => b.seconds - a.seconds) + + return { categoryList: list, totalSeconds: total } + }, [tasks, startDate, endDate, isAllPeriod, activeTaskId, activeSeconds]) + + // SVG Donut geometry + const size = 180 + const center = size / 2 + const radius = 64 + const strokeWidth = 22 + const circumference = 2 * Math.PI * radius + + const hoveredItem = categoryList.find((c) => c.id === hoveredId) || null + + let accumulatedLength = 0 + + return ( +
+ {/* Card Header */} +
+
+
+ +
+
+

+ Tempo Trabalhado por Categoria + +

+

+ {viewScope === 'mine' + ? 'Distribuição das suas horas no período' + : 'Distribuição das horas de toda a equipe no período'} +

+
+
+ + + + {periodLabel} + +
+ + {/* Main Content Area */} + {totalSeconds === 0 ? ( +
+
+ +
+

+ Nenhum tempo registrado para categorias neste período +

+

+ Inicie o cronômetro em demandas com categorias atribuídas para visualizar a distribuição visual de tempo aqui. +

+
+ ) : ( +
+ {/* Donut Chart (Left 5 cols) */} +
+ + {/* Background Track Circle */} + + + {/* Slices */} + {categoryList.map((item) => { + const sliceRatio = item.seconds / totalSeconds + const sliceLength = sliceRatio * circumference + const gap = categoryList.length > 1 ? 2.5 : 0 + const actualLength = Math.max(0, sliceLength - gap) + const offset = -accumulatedLength + accumulatedLength += sliceLength + + const isHovered = hoveredId === item.id + const currentStrokeWidth = isHovered ? strokeWidth + 4 : strokeWidth + + return ( + setHoveredId(item.id)} + onMouseLeave={() => setHoveredId(null)} + /> + ) + })} + + + {/* Donut Hole Center Content (Absolute) */} +
+ {hoveredItem ? ( +
+ + {hoveredItem.name} + +

+ {formatSecondsToTimer(hoveredItem.seconds)} +

+ + {hoveredItem.percentage.toFixed(1)}% do tempo + +
+ ) : ( +
+ + Total Auditado + +

+ {formatSecondsToTimer(totalSeconds)} +

+ + {categoryList.length} {categoryList.length === 1 ? 'categoria' : 'categorias'} + +
+ )} +
+
+ + {/* Interactive Legend & Bars (Right 7 cols) */} +
+
+ Categoria + Horas / % +
+ +
+ {categoryList.map((item) => { + const isHovered = hoveredId === item.id + + return ( +
setHoveredId(item.id)} + onMouseLeave={() => setHoveredId(null)} + className={`p-2 rounded-xl transition-all duration-150 cursor-pointer space-y-1.5 ${ + isHovered + ? 'bg-surface-raised ring-1 ring-border-soft shadow-xs' + : 'hover:bg-surface-raised/60' + }`} + > +
+
+ + + {item.name} + + + ({item.taskCount} {item.taskCount === 1 ? 'demanda' : 'demandas'}) + +
+ +
+ + {formatSecondsToHuman(item.seconds)} + + + {item.percentage.toFixed(1)}% + +
+
+ + {/* Progress Bar for the slice */} +
+
+
+
+ ) + })} +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard/CreateBiCardModal.tsx b/frontend/src/components/dashboard/CreateBiCardModal.tsx index a519b57..7c53202 100644 --- a/frontend/src/components/dashboard/CreateBiCardModal.tsx +++ b/frontend/src/components/dashboard/CreateBiCardModal.tsx @@ -10,6 +10,7 @@ import { Flame, ListOrdered, Layers, + Tag, } from 'lucide-react' import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences' import type { Project, Category } from '../../types/task' @@ -54,6 +55,12 @@ const METRIC_OPTIONS: { desc: 'Comparativo visual de horas e tarefas por iniciativa.', icon: BarChart3, }, + { + type: 'CATEGORY_BREAKDOWN', + label: 'Distribuição por Categoria', + desc: 'Divisão de horas dedicadas por categoria de tarefa.', + icon: Tag, + }, { type: 'STATUS_DISTRIBUTION', label: 'Distribuição por Status', diff --git a/frontend/src/lib/userPreferences.ts b/frontend/src/lib/userPreferences.ts index db82d6b..74dbf34 100644 --- a/frontend/src/lib/userPreferences.ts +++ b/frontend/src/lib/userPreferences.ts @@ -20,6 +20,7 @@ export type BiMetricType = | 'TASK_COUNT' | 'COMPLETION_RATE' | 'PROJECT_BREAKDOWN' + | 'CATEGORY_BREAKDOWN' | 'STATUS_DISTRIBUTION' | 'WORK_GOAL' | 'ACTIVE_TIMER' diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a14b3da..2765022 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -35,6 +35,7 @@ import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { GenerateReportModal } from '../components/reports/GenerateReportModal' import { PeriodSelector } from '../components/dashboard/PeriodSelector' +import { CategoryTimePieChart } from '../components/dashboard/CategoryTimePieChart' import { type PeriodPreset, getDateRangeForPreset, @@ -904,24 +905,38 @@ export const DashboardPage: React.FC = () => {
- {/* Recent Sprint Tasks (Right 7 cols) */} -
-
-

- - {viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'} - - {stats.periodLabel} - -

- - Ver todas - - -
+ {/* Right 7 cols: Category Time Pie Chart + Recent Tasks */} +
+ {/* Pie Chart: Time Worked by Category */} + + + {/* Recent Sprint Tasks */} +
+
+

+ + {viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'} + + {stats.periodLabel} + +

+ + Ver todas + + +
{loading ? (
@@ -1095,6 +1110,7 @@ export const DashboardPage: React.FC = () => { })}
)} +