feat(dashboard): add interactive category worked time pie chart to dashboard and bi v2
This commit is contained in:
parent
718c211391
commit
4dda0e0445
5 changed files with 424 additions and 18 deletions
|
|
@ -18,6 +18,7 @@ import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { DashboardV2Card } from '../../lib/userPreferences'
|
import type { DashboardV2Card } from '../../lib/userPreferences'
|
||||||
import type { Task, Project, Category } from '../../types/task'
|
import type { Task, Project, Category } from '../../types/task'
|
||||||
|
|
@ -365,6 +366,65 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'CATEGORY_BREAKDOWN': {
|
||||||
|
const catMap = new Map<string, { name: string; color: string; sec: number }>()
|
||||||
|
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 (
|
||||||
|
<div className="space-y-1.5 my-auto">
|
||||||
|
{topCats.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted italic">Nenhuma atividade por categoria.</p>
|
||||||
|
) : (
|
||||||
|
topCats.map((c) => {
|
||||||
|
const pct = total > 0 ? Math.round((c.sec / total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={c.name} className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-semibold text-foreground truncate max-w-[130px] flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: c.color }}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-muted text-[10px] font-bold">
|
||||||
|
{formatSecondsToTimer(c.sec)} ({pct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 rounded-full bg-surface-sunken overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: c.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'WORK_GOAL': {
|
case 'WORK_GOAL': {
|
||||||
const targetSeconds = (workGoal?.targetHours || 8) * 3600
|
const targetSeconds = (workGoal?.targetHours || 8) * 3600
|
||||||
const workedSeconds = calculateUserPeriodWorkedSeconds(
|
const workedSeconds = calculateUserPeriodWorkedSeconds(
|
||||||
|
|
@ -492,6 +552,8 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
return PieChart
|
return PieChart
|
||||||
case 'PROJECT_BREAKDOWN':
|
case 'PROJECT_BREAKDOWN':
|
||||||
return BarChart3
|
return BarChart3
|
||||||
|
case 'CATEGORY_BREAKDOWN':
|
||||||
|
return Tag
|
||||||
case 'WORK_GOAL':
|
case 'WORK_GOAL':
|
||||||
return Target
|
return Target
|
||||||
case 'ACTIVE_TIMER':
|
case 'ACTIVE_TIMER':
|
||||||
|
|
|
||||||
320
frontend/src/components/dashboard/CategoryTimePieChart.tsx
Normal file
320
frontend/src/components/dashboard/CategoryTimePieChart.tsx
Normal file
|
|
@ -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<CategoryTimePieChartProps> = ({
|
||||||
|
tasks,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
isAllPeriod = false,
|
||||||
|
activeTaskId,
|
||||||
|
activeSeconds,
|
||||||
|
viewScope,
|
||||||
|
periodLabel,
|
||||||
|
}) => {
|
||||||
|
const [hoveredId, setHoveredId] = useState<number | 'uncategorized' | null>(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 (
|
||||||
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 sm:p-6 shadow-panel space-y-5 transition-all hover:shadow-xl">
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 pb-3 border-b border-border-soft/70">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-accent-soft text-accent border border-accent/25 flex items-center justify-center shadow-xs">
|
||||||
|
<PieChart className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
<span>Tempo Trabalhado por Categoria</span>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-accent" />
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
{viewScope === 'mine'
|
||||||
|
? 'Distribuição das suas horas no período'
|
||||||
|
: 'Distribuição das horas de toda a equipe no período'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-surface-sunken border border-border-soft text-[11px] font-semibold text-muted self-start sm:self-auto">
|
||||||
|
<Clock className="w-3 h-3 text-brand" />
|
||||||
|
<span>{periodLabel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
{totalSeconds === 0 ? (
|
||||||
|
<div className="text-center py-12 px-4 rounded-2xl border border-dashed border-border-soft bg-surface-muted/30 space-y-2.5">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-surface-sunken border border-border-soft flex items-center justify-center text-muted mx-auto">
|
||||||
|
<Tag className="w-5 h-5 opacity-50" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-foreground">
|
||||||
|
Nenhum tempo registrado para categorias neste período
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-muted max-w-sm mx-auto">
|
||||||
|
Inicie o cronômetro em demandas com categorias atribuídas para visualizar a distribuição visual de tempo aqui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-center">
|
||||||
|
{/* Donut Chart (Left 5 cols) */}
|
||||||
|
<div className="md:col-span-5 flex flex-col items-center justify-center relative select-none">
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
className="transform -rotate-90"
|
||||||
|
>
|
||||||
|
{/* Background Track Circle */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="transparent"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-surface-sunken"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<circle
|
||||||
|
key={item.id}
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="transparent"
|
||||||
|
stroke={item.color}
|
||||||
|
strokeWidth={currentStrokeWidth}
|
||||||
|
strokeDasharray={`${actualLength} ${circumference}`}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-all duration-200 cursor-pointer"
|
||||||
|
style={{
|
||||||
|
opacity: hoveredId !== null && !isHovered ? 0.35 : 1,
|
||||||
|
filter: isHovered ? 'drop-shadow(0px 0px 4px rgba(0,0,0,0.35))' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredId(item.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Donut Hole Center Content (Absolute) */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none p-3"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{hoveredItem ? (
|
||||||
|
<div className="space-y-0.5 animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wider truncate max-w-[90px] block"
|
||||||
|
style={{ color: hoveredItem.color }}
|
||||||
|
>
|
||||||
|
{hoveredItem.name}
|
||||||
|
</span>
|
||||||
|
<p className="text-base font-extrabold font-mono text-foreground leading-tight">
|
||||||
|
{formatSecondsToTimer(hoveredItem.seconds)}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] font-semibold text-muted block">
|
||||||
|
{hoveredItem.percentage.toFixed(1)}% do tempo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5 animate-in fade-in duration-150">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider text-muted block">
|
||||||
|
Total Auditado
|
||||||
|
</span>
|
||||||
|
<p className="text-base font-extrabold font-mono text-foreground leading-tight">
|
||||||
|
{formatSecondsToTimer(totalSeconds)}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] font-semibold text-muted block">
|
||||||
|
{categoryList.length} {categoryList.length === 1 ? 'categoria' : 'categorias'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Legend & Bars (Right 7 cols) */}
|
||||||
|
<div className="md:col-span-7 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-bold text-muted uppercase tracking-wider pb-1 border-b border-border-soft/60">
|
||||||
|
<span>Categoria</span>
|
||||||
|
<span>Horas / %</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
|
||||||
|
{categoryList.map((item) => {
|
||||||
|
const isHovered = hoveredId === item.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onMouseEnter={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
transform: isHovered ? 'scale(1.25)' : 'scale(1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-semibold truncate transition-colors ${
|
||||||
|
isHovered ? 'text-brand font-bold' : 'text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-normal shrink-0">
|
||||||
|
({item.taskCount} {item.taskCount === 1 ? 'demanda' : 'demandas'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 font-mono text-[11px]">
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{formatSecondsToHuman(item.seconds)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted w-11 text-right font-sans font-semibold">
|
||||||
|
{item.percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for the slice */}
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-surface-sunken overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${item.percentage}%`,
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Flame,
|
Flame,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Layers,
|
Layers,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
|
import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
|
||||||
import type { Project, Category } from '../../types/task'
|
import type { Project, Category } from '../../types/task'
|
||||||
|
|
@ -54,6 +55,12 @@ const METRIC_OPTIONS: {
|
||||||
desc: 'Comparativo visual de horas e tarefas por iniciativa.',
|
desc: 'Comparativo visual de horas e tarefas por iniciativa.',
|
||||||
icon: BarChart3,
|
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',
|
type: 'STATUS_DISTRIBUTION',
|
||||||
label: 'Distribuição por Status',
|
label: 'Distribuição por Status',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export type BiMetricType =
|
||||||
| 'TASK_COUNT'
|
| 'TASK_COUNT'
|
||||||
| 'COMPLETION_RATE'
|
| 'COMPLETION_RATE'
|
||||||
| 'PROJECT_BREAKDOWN'
|
| 'PROJECT_BREAKDOWN'
|
||||||
|
| 'CATEGORY_BREAKDOWN'
|
||||||
| 'STATUS_DISTRIBUTION'
|
| 'STATUS_DISTRIBUTION'
|
||||||
| 'WORK_GOAL'
|
| 'WORK_GOAL'
|
||||||
| 'ACTIVE_TIMER'
|
| 'ACTIVE_TIMER'
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer }
|
||||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||||
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||||
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||||
|
import { CategoryTimePieChart } from '../components/dashboard/CategoryTimePieChart'
|
||||||
import {
|
import {
|
||||||
type PeriodPreset,
|
type PeriodPreset,
|
||||||
getDateRangeForPreset,
|
getDateRangeForPreset,
|
||||||
|
|
@ -904,24 +905,38 @@ export const DashboardPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Sprint Tasks (Right 7 cols) */}
|
{/* Right 7 cols: Category Time Pie Chart + Recent Tasks */}
|
||||||
<div className="lg:col-span-7 space-y-4">
|
<div className="lg:col-span-7 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Pie Chart: Time Worked by Category */}
|
||||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 flex-wrap">
|
<CategoryTimePieChart
|
||||||
<Zap className="w-4 h-4 text-accent" />
|
tasks={scopeTasks}
|
||||||
<span>{viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'}</span>
|
startDate={activeDateRange.startDate}
|
||||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-brand-soft text-brand border border-brand/20">
|
endDate={activeDateRange.endDate}
|
||||||
{stats.periodLabel}
|
isAllPeriod={periodPreset === 'all'}
|
||||||
</span>
|
activeTaskId={currentActiveTask?.id}
|
||||||
</h2>
|
activeSeconds={activeSeconds}
|
||||||
<Link
|
viewScope={viewScope}
|
||||||
to="/tasks"
|
periodLabel={stats.periodLabel}
|
||||||
className="text-xs font-bold text-brand hover:underline flex items-center gap-1"
|
/>
|
||||||
>
|
|
||||||
<span>Ver todas</span>
|
{/* Recent Sprint Tasks */}
|
||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<div className="space-y-4">
|
||||||
</Link>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 flex-wrap">
|
||||||
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
|
<span>{viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'}</span>
|
||||||
|
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-brand-soft text-brand border border-brand/20">
|
||||||
|
{stats.periodLabel}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/tasks"
|
||||||
|
className="text-xs font-bold text-brand hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>Ver todas</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -1095,6 +1110,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue