feat(analytics-profile): implement Workspace Analytics and User Profile pages
This commit is contained in:
parent
297a2ed9e4
commit
61128c0a1a
3 changed files with 735 additions and 2 deletions
|
|
@ -12,6 +12,8 @@ import { ProjectsPage } from './pages/ProjectsPage'
|
||||||
import { CategoriesPage } from './pages/CategoriesPage'
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
import { MembersPage } from './pages/MembersPage'
|
import { MembersPage } from './pages/MembersPage'
|
||||||
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
||||||
|
import { AnalyticsPage } from './pages/AnalyticsPage'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -38,10 +40,10 @@ export function App() {
|
||||||
<Route path="/kanban" element={<TasksPage />} />
|
<Route path="/kanban" element={<TasksPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
<Route path="/analytics" element={<DashboardPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/members" element={<MembersPage />} />
|
<Route path="/members" element={<MembersPage />} />
|
||||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||||
<Route path="/profile" element={<DashboardPage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
|
|
|
||||||
535
new-frontend/src/pages/AnalyticsPage.tsx
Normal file
535
new-frontend/src/pages/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
TrendingUp,
|
||||||
|
Folder,
|
||||||
|
Tag,
|
||||||
|
Users,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { taskService } from '../services/taskService'
|
||||||
|
import { projectService } from '../services/projectService'
|
||||||
|
import { categoryService } from '../services/categoryService'
|
||||||
|
import { organizationService, type OrganizationMember } from '../services/organizationService'
|
||||||
|
import type { Task, Project, Category } from '../types/task'
|
||||||
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
|
|
||||||
|
export const AnalyticsPage: React.FC = () => {
|
||||||
|
const { activeOrg } = useAuth()
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [members, setMembers] = useState<OrganizationMember[]>([])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!activeOrgId) return
|
||||||
|
try {
|
||||||
|
setRefreshing(true)
|
||||||
|
const [tasksRes, projsRes, catsRes, memsRes] = await Promise.allSettled([
|
||||||
|
taskService.listTasks({ organizationId: activeOrgId }),
|
||||||
|
projectService.listProjects(activeOrgId),
|
||||||
|
categoryService.listCategories(activeOrgId),
|
||||||
|
organizationService.listMembers(activeOrgId),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (tasksRes.status === 'fulfilled') setTasks(tasksRes.value || [])
|
||||||
|
if (projsRes.status === 'fulfilled') setProjects(projsRes.value || [])
|
||||||
|
if (catsRes.status === 'fulfilled') setCategories(catsRes.value || [])
|
||||||
|
if (memsRes.status === 'fulfilled') setMembers(memsRes.value || [])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Falha ao calcular métricas do workspace.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [activeOrgId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// Aggregate high-level KPIs
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
const totalTasks = tasks.length
|
||||||
|
const completedTasks = tasks.filter((t) => t.status === 'COMPLETED').length
|
||||||
|
const inProgressTasks = tasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||||
|
const notStartedTasks = tasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||||
|
const canceledTasks = tasks.filter((t) => t.status === 'CANCELED').length
|
||||||
|
|
||||||
|
const totalSecondsTracked = tasks.reduce(
|
||||||
|
(acc, t) => acc + calculateTaskTotalSeconds(t),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
const completionRate =
|
||||||
|
totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||||
|
|
||||||
|
const avgSecondsPerCompletedTask =
|
||||||
|
completedTasks > 0
|
||||||
|
? Math.round(
|
||||||
|
tasks
|
||||||
|
.filter((t) => t.status === 'COMPLETED')
|
||||||
|
.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTasks,
|
||||||
|
completedTasks,
|
||||||
|
inProgressTasks,
|
||||||
|
notStartedTasks,
|
||||||
|
canceledTasks,
|
||||||
|
totalSecondsTracked,
|
||||||
|
completionRate,
|
||||||
|
avgSecondsPerCompletedTask,
|
||||||
|
}
|
||||||
|
}, [tasks])
|
||||||
|
|
||||||
|
// Project breakdown
|
||||||
|
const projectBreakdown = useMemo(() => {
|
||||||
|
const map = new Map<number, { project: Project; seconds: number; taskCount: number }>()
|
||||||
|
|
||||||
|
projects.forEach((p) => {
|
||||||
|
map.set(p.id, { project: p, seconds: 0, taskCount: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
let unassignedSeconds = 0
|
||||||
|
let unassignedTasks = 0
|
||||||
|
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
const sec = calculateTaskTotalSeconds(t)
|
||||||
|
if (t.project?.id && map.has(t.project.id)) {
|
||||||
|
const item = map.get(t.project.id)!
|
||||||
|
item.seconds += sec
|
||||||
|
item.taskCount += 1
|
||||||
|
} else {
|
||||||
|
unassignedSeconds += sec
|
||||||
|
unassignedTasks += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Array.from(map.values())
|
||||||
|
if (unassignedTasks > 0) {
|
||||||
|
list.push({
|
||||||
|
project: { id: 0, name: 'Sem Projeto', color: '#64748b' },
|
||||||
|
seconds: unassignedSeconds,
|
||||||
|
taskCount: unassignedTasks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.sort((a, b) => b.seconds - a.seconds)
|
||||||
|
}, [projects, tasks])
|
||||||
|
|
||||||
|
// Category breakdown
|
||||||
|
const categoryBreakdown = useMemo(() => {
|
||||||
|
const map = new Map<number, { category: Category; seconds: number; taskCount: number }>()
|
||||||
|
|
||||||
|
categories.forEach((c) => {
|
||||||
|
map.set(c.id, { category: c, seconds: 0, taskCount: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
let unassignedSeconds = 0
|
||||||
|
let unassignedTasks = 0
|
||||||
|
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
const sec = calculateTaskTotalSeconds(t)
|
||||||
|
if (t.category?.id && map.has(t.category.id)) {
|
||||||
|
const item = map.get(t.category.id)!
|
||||||
|
item.seconds += sec
|
||||||
|
item.taskCount += 1
|
||||||
|
} else {
|
||||||
|
unassignedSeconds += sec
|
||||||
|
unassignedTasks += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Array.from(map.values())
|
||||||
|
if (unassignedTasks > 0) {
|
||||||
|
list.push({
|
||||||
|
category: { id: 0, name: 'Sem Categoria', color: '#64748b' },
|
||||||
|
seconds: unassignedSeconds,
|
||||||
|
taskCount: unassignedTasks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.sort((a, b) => b.seconds - a.seconds)
|
||||||
|
}, [categories, tasks])
|
||||||
|
|
||||||
|
// Member breakdown
|
||||||
|
const memberBreakdown = useMemo(() => {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; email: string; seconds: number; completedCount: number; totalCount: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
members.forEach((m) => {
|
||||||
|
map.set(m.userEmail, {
|
||||||
|
name: m.userName,
|
||||||
|
email: m.userEmail,
|
||||||
|
seconds: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
const sec = calculateTaskTotalSeconds(t)
|
||||||
|
const email = t.user?.email || 'unassigned@bee.internal'
|
||||||
|
if (!map.has(email)) {
|
||||||
|
map.set(email, {
|
||||||
|
name: t.user?.name || 'Não atribuído',
|
||||||
|
email,
|
||||||
|
seconds: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const item = map.get(email)!
|
||||||
|
item.seconds += sec
|
||||||
|
item.totalCount += 1
|
||||||
|
if (t.status === 'COMPLETED') item.completedCount += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.seconds - a.seconds)
|
||||||
|
}, [members, tasks])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Analytics & Produtividade
|
||||||
|
</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25">
|
||||||
|
{activeOrg?.name || 'Workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Auditoria de tempo investido, distribuição por iniciativas e acompanhamento da vazão da equipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Recarregar métricas"
|
||||||
|
className="self-start sm:self-auto p-2 rounded-xl border border-border bg-surface text-muted hover:text-foreground hover:bg-surface-raised transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-rose-400" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-rose-400 hover:text-rose-200 text-xs font-bold"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-32 rounded-2xl border border-border bg-surface-sunken/40 animate-pulse p-4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Top 4 KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Time */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface flex flex-col justify-between shadow-xs">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold text-muted">Tempo Total Dedicado</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-400">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-foreground tracking-tight font-mono">
|
||||||
|
{formatSecondsToTimer(kpis.totalSecondsTracked)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Rate */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface flex flex-col justify-between shadow-xs">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold text-muted">Taxa de Conclusão</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-emerald-400 tracking-tight">
|
||||||
|
{kpis.completionRate}%
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In Progress vs Backlog */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface flex flex-col justify-between shadow-xs">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold text-muted">Em Execução Agora</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-teal-300 tracking-tight">
|
||||||
|
{kpis.inProgressTasks} tarefas
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
{kpis.notStartedTasks} no backlog aguardando início
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avg Time per Task */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface flex flex-col justify-between shadow-xs">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold text-muted">Média por Entrega</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-extrabold text-foreground tracking-tight font-mono">
|
||||||
|
{formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
Tempo médio gasto em cada entrega concluída
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Grid: Projects & Categories Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Project Time Distribution */}
|
||||||
|
<div className="p-6 rounded-2xl border border-border bg-surface shadow-xs space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
|
||||||
|
<Folder className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-bold text-foreground">
|
||||||
|
Tempo por Projeto
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted font-medium">
|
||||||
|
{projectBreakdown.length} projetos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectBreakdown.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted py-8 text-center">Nenhum dado de projeto disponível.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projectBreakdown.map((item) => {
|
||||||
|
const pct =
|
||||||
|
kpis.totalSecondsTracked > 0
|
||||||
|
? Math.round((item.seconds / kpis.totalSecondsTracked) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<div key={item.project.id} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: item.project.color || '#0f766e' }}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-foreground truncate">
|
||||||
|
{item.project.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-normal">
|
||||||
|
({item.taskCount} {item.taskCount === 1 ? 'tarefa' : 'tarefas'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 font-mono text-[11px]">
|
||||||
|
<span className="text-foreground font-bold">
|
||||||
|
{formatSecondsToHuman(item.seconds)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted w-10 text-right font-sans">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-surface-sunken overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: item.project.color || '#0f766e',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Time Distribution */}
|
||||||
|
<div className="p-6 rounded-2xl border border-border bg-surface shadow-xs space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-bold text-foreground">
|
||||||
|
Tempo por Categoria / Tag
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted font-medium">
|
||||||
|
{categoryBreakdown.length} categorias
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoryBreakdown.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted py-8 text-center">Nenhum dado de categoria disponível.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categoryBreakdown.map((item) => {
|
||||||
|
const pct =
|
||||||
|
kpis.totalSecondsTracked > 0
|
||||||
|
? Math.round((item.seconds / kpis.totalSecondsTracked) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<div key={item.category.id} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: item.category.color || '#d97706' }}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-foreground truncate">
|
||||||
|
{item.category.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-normal">
|
||||||
|
({item.taskCount} {item.taskCount === 1 ? 'tarefa' : 'tarefas'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 font-mono text-[11px]">
|
||||||
|
<span className="text-foreground font-bold">
|
||||||
|
{formatSecondsToHuman(item.seconds)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted w-10 text-right font-sans">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-surface-sunken overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: item.category.color || '#d97706',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Productivity Table */}
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-6 shadow-xs space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-bold text-foreground">
|
||||||
|
Dedicação por Membro da Equipe
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted font-medium">
|
||||||
|
{memberBreakdown.length} colaboradores
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-sunken/40 text-muted font-semibold uppercase tracking-wider text-[11px]">
|
||||||
|
<th className="py-3 px-4">Membro</th>
|
||||||
|
<th className="py-3 px-4">Tarefas Atribuídas</th>
|
||||||
|
<th className="py-3 px-4">Entregas Concluídas</th>
|
||||||
|
<th className="py-3 px-4">Tempo Total Registrado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{memberBreakdown.map((member, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-surface-raised/50 transition-colors">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-brand/15 border border-brand/30 flex items-center justify-center font-bold text-brand uppercase text-[10px]">
|
||||||
|
{member.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">{member.name}</p>
|
||||||
|
<p className="text-[10px] text-muted">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-medium text-foreground">
|
||||||
|
{member.totalCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-400 font-semibold">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
{member.completedCount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono font-bold text-teal-300">
|
||||||
|
{formatSecondsToTimer(member.seconds)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
new-frontend/src/pages/ProfilePage.tsx
Normal file
196
new-frontend/src/pages/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
LogOut,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { taskService } from '../services/taskService'
|
||||||
|
import type { Task } from '../types/task'
|
||||||
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
|
|
||||||
|
export const ProfilePage: React.FC = () => {
|
||||||
|
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [myTasks, setMyTasks] = useState<Task[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeOrg?.id) return
|
||||||
|
const loadMyTasks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await taskService.listMyTasks({ organizationId: activeOrg.id })
|
||||||
|
setMyTasks(res || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load my tasks', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadMyTasks()
|
||||||
|
}, [activeOrg?.id])
|
||||||
|
|
||||||
|
const myTotalSeconds = myTasks.reduce(
|
||||||
|
(acc, t) => acc + calculateTaskTotalSeconds(t),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const myCompletedCount = myTasks.filter((t) => t.status === 'COMPLETED').length
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-1">
|
||||||
|
Meu Perfil & Conta
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Gerencie seus dados pessoais, credenciais de acesso e visualize seu desempenho.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Profile Info Card */}
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-6 shadow-xs space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-brand to-teal-700 border-2 border-brand/40 flex items-center justify-center text-white font-extrabold text-2xl shadow-lg shadow-brand/20">
|
||||||
|
{user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">{user.name || 'Usuário'}</h2>
|
||||||
|
<p className="text-xs text-muted flex items-center gap-1.5 mt-0.5">
|
||||||
|
<Mail className="w-3.5 h-3.5" />
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="self-start sm:self-auto flex items-center gap-2 px-4 py-2 rounded-xl border border-rose-500/30 text-rose-300 hover:bg-rose-500/10 text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span>Encerrar Sessão</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Performance KPIs */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted mb-3">
|
||||||
|
Meu Desempenho no Workspace Atual ({activeOrg?.name})
|
||||||
|
</h3>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted gap-2 text-xs rounded-xl bg-surface-raised border border-border">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-brand" />
|
||||||
|
<span>Carregando estatísticas pessoais...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Tempo Registrado
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-extrabold text-teal-300 font-mono">
|
||||||
|
{formatSecondsToTimer(myTotalSeconds)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted block mt-0.5">
|
||||||
|
({formatSecondsToHuman(myTotalSeconds)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Tarefas Concluídas
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-extrabold text-emerald-400">
|
||||||
|
{myCompletedCount} entregas
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted block mt-0.5">
|
||||||
|
de {myTasks.length} atribuídas a você
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Workspace Ativo
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-foreground truncate block">
|
||||||
|
{activeOrg?.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-brand font-semibold block mt-0.5">
|
||||||
|
{activeOrg?.roles.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Workspaces */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted">
|
||||||
|
Meus Workspaces Cadastrados
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/organizations')}
|
||||||
|
className="text-xs font-semibold text-brand hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>Gerenciar</span>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{user.organizations.map((org) => {
|
||||||
|
const isCurrent = org.id === activeOrg?.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={org.id}
|
||||||
|
onClick={() => setActiveOrg(org.id)}
|
||||||
|
className={`p-3.5 rounded-xl border cursor-pointer transition-all flex items-center justify-between gap-3 ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-brand bg-brand/5 ring-1 ring-brand/30'
|
||||||
|
: 'border-border bg-surface-raised hover:border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-9 h-9 rounded-lg flex items-center justify-center font-bold text-xs ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-brand text-white'
|
||||||
|
: 'bg-surface-sunken text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{org.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-foreground">{org.name}</h4>
|
||||||
|
<p className="text-[10px] text-muted">{org.roles.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-brand/20 text-brand text-[10px] font-bold">
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue