feat(projects-categories): implement full Projects and Categories management pages with time analytics
This commit is contained in:
parent
1806b08724
commit
640af48a9c
4 changed files with 897 additions and 2 deletions
|
|
@ -8,6 +8,8 @@ import { AppShell } from './components/layout/AppShell'
|
||||||
import { DashboardPage } from './pages/DashboardPage'
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
import { OrganizationsPage } from './pages/OrganizationsPage'
|
import { OrganizationsPage } from './pages/OrganizationsPage'
|
||||||
import { TasksPage } from './pages/TasksPage'
|
import { TasksPage } from './pages/TasksPage'
|
||||||
|
import { ProjectsPage } from './pages/ProjectsPage'
|
||||||
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,8 +34,8 @@ export function App() {
|
||||||
<Route path="/organizations" element={<OrganizationsPage />} />
|
<Route path="/organizations" element={<OrganizationsPage />} />
|
||||||
<Route path="/tasks" element={<TasksPage />} />
|
<Route path="/tasks" element={<TasksPage />} />
|
||||||
<Route path="/kanban" element={<TasksPage />} />
|
<Route path="/kanban" element={<TasksPage />} />
|
||||||
<Route path="/projects" element={<DashboardPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/categories" element={<DashboardPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
<Route path="/analytics" element={<DashboardPage />} />
|
<Route path="/analytics" element={<DashboardPage />} />
|
||||||
<Route path="/members" element={<OrganizationsPage />} />
|
<Route path="/members" element={<OrganizationsPage />} />
|
||||||
<Route path="/organization/settings" element={<OrganizationsPage />} />
|
<Route path="/organization/settings" element={<OrganizationsPage />} />
|
||||||
|
|
|
||||||
446
new-frontend/src/pages/CategoriesPage.tsx
Normal file
446
new-frontend/src/pages/CategoriesPage.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Tag,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
ListTodo,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
Edit2,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { categoryService } from '../services/categoryService'
|
||||||
|
import { taskService } from '../services/taskService'
|
||||||
|
import type { Category, Task } from '../types/task'
|
||||||
|
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../lib/timeUtils'
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#d97706', // Amber / Accent
|
||||||
|
'#0f766e', // Teal / Brand
|
||||||
|
'#10b981', // Emerald
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
'#8b5cf6', // Violet
|
||||||
|
'#ec4899', // Pink
|
||||||
|
'#f43f5e', // Rose
|
||||||
|
'#f59e0b', // Yellow
|
||||||
|
'#64748b', // Slate
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CategoriesPage: React.FC = () => {
|
||||||
|
const { activeOrg } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [color, setColor] = useState(COLOR_PRESETS[0])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!activeOrgId) return
|
||||||
|
try {
|
||||||
|
setRefreshing(true)
|
||||||
|
const [catsRes, tasksRes] = await Promise.all([
|
||||||
|
categoryService.listCategories(activeOrgId),
|
||||||
|
taskService.listTasks({ organizationId: activeOrgId }).catch(() => []),
|
||||||
|
])
|
||||||
|
setCategories(catsRes || [])
|
||||||
|
setTasks(tasksRes || [])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Falha ao carregar categorias.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [activeOrgId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// Aggregate category statistics
|
||||||
|
const categoryStats = useMemo(() => {
|
||||||
|
const statsMap = new Map<
|
||||||
|
number,
|
||||||
|
{ totalTasks: number; totalSeconds: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (!task.category?.id) return
|
||||||
|
const cId = task.category.id
|
||||||
|
const current = statsMap.get(cId) || {
|
||||||
|
totalTasks: 0,
|
||||||
|
totalSeconds: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
current.totalTasks += 1
|
||||||
|
current.totalSeconds += calculateTaskTotalSeconds(task)
|
||||||
|
statsMap.set(cId, current)
|
||||||
|
})
|
||||||
|
|
||||||
|
return statsMap
|
||||||
|
}, [tasks])
|
||||||
|
|
||||||
|
const filteredCategories = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return categories
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
return categories.filter((c) => c.name.toLowerCase().includes(q))
|
||||||
|
}, [categories, searchQuery])
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingCategory(null)
|
||||||
|
setName('')
|
||||||
|
setColor(COLOR_PRESETS[0])
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenEditModal = (cat: Category) => {
|
||||||
|
setEditingCategory(cat)
|
||||||
|
setName(cat.name)
|
||||||
|
setColor(cat.color || COLOR_PRESETS[0])
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCategory = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!activeOrgId || !name.trim()) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
const updated = await categoryService.updateCategory(activeOrgId, editingCategory.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
color,
|
||||||
|
icon: 'tag',
|
||||||
|
})
|
||||||
|
setCategories((prev) =>
|
||||||
|
prev.map((c) => (c.id === updated.id ? updated : c))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const created = await categoryService.createCategory(activeOrgId, {
|
||||||
|
name: name.trim(),
|
||||||
|
color,
|
||||||
|
icon: 'tag',
|
||||||
|
})
|
||||||
|
setCategories((prev) => [...prev, created])
|
||||||
|
}
|
||||||
|
setIsModalOpen(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Falha ao salvar categoria.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Categorias & Tags
|
||||||
|
</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-accent/15 text-accent border border-accent/25">
|
||||||
|
{activeOrg?.name || 'Workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Classifique suas tarefas por tipos de trabalho (Bug, Feature, Reunião, Design) para métricas precisas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Recarregar categorias"
|
||||||
|
className="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-accent' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-accent text-white font-semibold text-xs hover:bg-accent-hover shadow-md shadow-accent/20 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nova Categoria</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="p-3.5 rounded-2xl border border-border bg-surface flex items-center justify-between gap-3 shadow-xs">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Buscar categoria por nome..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border bg-surface-raised text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted font-medium">
|
||||||
|
{filteredCategories.length} {filteredCategories.length === 1 ? 'categoria' : 'categorias'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Categories */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-40 rounded-2xl border border-border bg-surface-sunken/40 animate-pulse p-5 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="h-5 bg-surface-raised rounded-md w-1/2" />
|
||||||
|
<div className="h-3 bg-surface-raised rounded-md w-3/4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredCategories.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 px-4 text-center rounded-2xl border border-dashed border-border bg-surface/50">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-accent/10 border border-accent/20 flex items-center justify-center text-accent mb-4">
|
||||||
|
<Tag className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-foreground mb-1">
|
||||||
|
Nenhuma categoria cadastrada
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted max-w-sm mb-5 leading-relaxed">
|
||||||
|
{searchQuery
|
||||||
|
? 'Nenhuma categoria encontrada com este termo.'
|
||||||
|
: 'Categorias permitem segmentar seus fluxos de trabalho e auditar onde o tempo é consumido.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-accent text-white text-xs font-semibold hover:bg-accent-hover shadow-md shadow-accent/20 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Criar Primeira Categoria</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{filteredCategories.map((category) => {
|
||||||
|
const stats = categoryStats.get(category.id) || {
|
||||||
|
totalTasks: 0,
|
||||||
|
totalSeconds: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className="group rounded-2xl border border-border bg-surface p-5 hover:border-zinc-700 hover:shadow-lg transition-all duration-200 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center border shadow-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: category.color ? `${category.color}18` : 'rgba(217,119,6,0.15)',
|
||||||
|
borderColor: category.color ? `${category.color}40` : 'rgba(217,119,6,0.3)',
|
||||||
|
color: category.color || '#d97706',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-foreground group-hover:text-accent transition-colors">
|
||||||
|
{category.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] text-muted font-medium">
|
||||||
|
ID #{category.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenEditModal(category)}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
||||||
|
title="Editar categoria"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Bar */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3 rounded-xl bg-surface-sunken/60 border border-border/60 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="w-3.5 h-3.5 text-muted" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted">Tarefas</p>
|
||||||
|
<p className="text-xs font-bold text-foreground">
|
||||||
|
{stats.totalTasks} associadas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted">Tempo Total</p>
|
||||||
|
<p className="text-xs font-bold text-amber-400">
|
||||||
|
{formatSecondsToHuman(stats.totalSeconds)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Link */}
|
||||||
|
<div className="pt-3 border-t border-border/80 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="inline-block w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: category.color || '#d97706' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/tasks')}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-accent hover:underline"
|
||||||
|
>
|
||||||
|
<span>Ver no Quadro</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Criar/Editar Categoria */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/70 backdrop-blur-xs"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl border border-border bg-surface-raised shadow-2xl z-10 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-border bg-surface">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-accent/15 border border-accent/30 flex items-center justify-center text-accent">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">
|
||||||
|
{editingCategory ? 'Editar Categoria' : 'Nova Categoria'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{editingCategory
|
||||||
|
? 'Atualize o nome ou cor da tag.'
|
||||||
|
: 'Crie uma categoria para agrupar tipos de trabalho.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveCategory} className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||||
|
Nome da Categoria <span className="text-accent">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ex: Bugfix, Feature, Reunião, Documentação..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-2">
|
||||||
|
Cor da Tag
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{COLOR_PRESETS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-transform ${
|
||||||
|
color === c
|
||||||
|
? 'scale-115 border-white ring-2 ring-accent/50'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-border flex items-center justify-end gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !name.trim()}
|
||||||
|
className="px-5 py-2 rounded-xl bg-accent text-white text-xs font-semibold hover:bg-accent-hover shadow-xs flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
{editingCategory ? 'Salvar Alterações' : 'Criar Categoria'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
new-frontend/src/pages/ProjectsPage.tsx
Normal file
436
new-frontend/src/pages/ProjectsPage.tsx
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
ListTodo,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
Folder,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { projectService } from '../services/projectService'
|
||||||
|
import { taskService } from '../services/taskService'
|
||||||
|
import type { Project, Task } from '../types/task'
|
||||||
|
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../lib/timeUtils'
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#0f766e', // Teal / Brand
|
||||||
|
'#d97706', // Amber / Accent
|
||||||
|
'#10b981', // Emerald
|
||||||
|
'#3b82f6', // Blue
|
||||||
|
'#6366f1', // Indigo
|
||||||
|
'#8b5cf6', // Violet
|
||||||
|
'#ec4899', // Pink
|
||||||
|
'#f43f5e', // Rose
|
||||||
|
'#14b8a6', // Cyan
|
||||||
|
'#64748b', // Slate
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ProjectsPage: React.FC = () => {
|
||||||
|
const { activeOrg } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [color, setColor] = useState(COLOR_PRESETS[0])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!activeOrgId) return
|
||||||
|
try {
|
||||||
|
setRefreshing(true)
|
||||||
|
const [projsRes, tasksRes] = await Promise.all([
|
||||||
|
projectService.listProjects(activeOrgId),
|
||||||
|
taskService.listTasks({ organizationId: activeOrgId }).catch(() => []),
|
||||||
|
])
|
||||||
|
setProjects(projsRes || [])
|
||||||
|
setTasks(tasksRes || [])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Falha ao carregar projetos.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [activeOrgId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
// Aggregate project statistics
|
||||||
|
const projectStats = useMemo(() => {
|
||||||
|
const statsMap = new Map<
|
||||||
|
number,
|
||||||
|
{ totalTasks: number; completedTasks: number; totalSeconds: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (!task.project?.id) return
|
||||||
|
const pId = task.project.id
|
||||||
|
const current = statsMap.get(pId) || {
|
||||||
|
totalTasks: 0,
|
||||||
|
completedTasks: 0,
|
||||||
|
totalSeconds: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
current.totalTasks += 1
|
||||||
|
if (task.status === 'COMPLETED') {
|
||||||
|
current.completedTasks += 1
|
||||||
|
}
|
||||||
|
current.totalSeconds += calculateTaskTotalSeconds(task)
|
||||||
|
statsMap.set(pId, current)
|
||||||
|
})
|
||||||
|
|
||||||
|
return statsMap
|
||||||
|
}, [tasks])
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return projects
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
return projects.filter((p) => p.name.toLowerCase().includes(q))
|
||||||
|
}, [projects, searchQuery])
|
||||||
|
|
||||||
|
const handleCreateProject = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!activeOrgId || !name.trim()) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const created = await projectService.createProject(activeOrgId, {
|
||||||
|
name: name.trim(),
|
||||||
|
color,
|
||||||
|
icon: 'folder',
|
||||||
|
})
|
||||||
|
setProjects((prev) => [...prev, created])
|
||||||
|
setIsModalOpen(false)
|
||||||
|
setName('')
|
||||||
|
setColor(COLOR_PRESETS[0])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Falha ao criar projeto.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Projetos
|
||||||
|
</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">
|
||||||
|
Organize suas entregas em iniciativas, acompanhe o progresso e o tempo dedicado.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Recarregar projetos"
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/20 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Novo Projeto</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="p-3.5 rounded-2xl border border-border bg-surface flex items-center justify-between gap-3 shadow-xs">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Buscar projeto por nome..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border bg-surface-raised text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted font-medium">
|
||||||
|
{filteredProjects.length} {filteredProjects.length === 1 ? 'projeto' : 'projetos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Projects */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-48 rounded-2xl border border-border bg-surface-sunken/40 animate-pulse p-5 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="h-5 bg-surface-raised rounded-md w-1/2" />
|
||||||
|
<div className="h-3 bg-surface-raised rounded-md w-3/4" />
|
||||||
|
<div className="h-2 bg-surface-raised rounded-full w-full mt-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 px-4 text-center rounded-2xl border border-dashed border-border bg-surface/50">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-brand/10 border border-brand/20 flex items-center justify-center text-brand mb-4">
|
||||||
|
<FolderKanban className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-bold text-foreground mb-1">
|
||||||
|
Nenhum projeto cadastrado
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted max-w-sm mb-5 leading-relaxed">
|
||||||
|
{searchQuery
|
||||||
|
? 'Nenhum projeto encontrado com este termo de busca.'
|
||||||
|
: 'Projetos ajudam a agrupar demandas por produtos, clientes ou sprints.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Criar Primeiro Projeto</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{filteredProjects.map((project) => {
|
||||||
|
const stats = projectStats.get(project.id) || {
|
||||||
|
totalTasks: 0,
|
||||||
|
completedTasks: 0,
|
||||||
|
totalSeconds: 0,
|
||||||
|
}
|
||||||
|
const progress =
|
||||||
|
stats.totalTasks > 0
|
||||||
|
? Math.round((stats.completedTasks / stats.totalTasks) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="group rounded-2xl border border-border bg-surface p-5 hover:border-zinc-700 hover:shadow-lg transition-all duration-200 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{/* Top Bar with Icon & Color badge */}
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center border shadow-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: project.color ? `${project.color}18` : 'rgba(15,118,110,0.15)',
|
||||||
|
borderColor: project.color ? `${project.color}40` : 'rgba(15,118,110,0.3)',
|
||||||
|
color: project.color || '#0f766e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-foreground group-hover:text-brand transition-colors">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] text-muted font-medium">
|
||||||
|
ID #{project.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Bar */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-3 rounded-xl bg-surface-sunken/60 border border-border/60 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="w-3.5 h-3.5 text-muted" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted">Tarefas</p>
|
||||||
|
<p className="text-xs font-bold text-foreground">
|
||||||
|
{stats.totalTasks} ({stats.completedTasks} concl.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted">Tempo Total</p>
|
||||||
|
<p className="text-xs font-bold text-teal-400">
|
||||||
|
{formatSecondsToHuman(stats.totalSeconds)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-muted font-medium">Conclusão</span>
|
||||||
|
<span className="font-bold text-foreground">{progress}%</span>
|
||||||
|
</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: `${progress}%`,
|
||||||
|
backgroundColor: project.color || '#0f766e',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Link */}
|
||||||
|
<div className="pt-3 border-t border-border/80 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
{stats.completedTasks} entregues
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/tasks')}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-brand hover:underline"
|
||||||
|
>
|
||||||
|
<span>Ver Tarefas</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Novo Projeto */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/70 backdrop-blur-xs"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl border border-border bg-surface-raised shadow-2xl z-10 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-border bg-surface">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Novo Projeto</h2>
|
||||||
|
<p className="text-xs text-muted">Crie um projeto para agrupar demandas.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateProject} className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||||
|
Nome do Projeto <span className="text-brand">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ex: Plataforma Web v3, App Mobile, API..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-foreground mb-2">
|
||||||
|
Cor de Identificação
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{COLOR_PRESETS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-7 h-7 rounded-full border-2 transition-transform ${
|
||||||
|
color === c
|
||||||
|
? 'scale-115 border-white ring-2 ring-brand/50'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-border flex items-center justify-end gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !name.trim()}
|
||||||
|
className="px-5 py-2 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-xs flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
Salvar Projeto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,4 +12,15 @@ export const categoryService = {
|
||||||
body: data,
|
body: data,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateCategory(
|
||||||
|
orgId: number,
|
||||||
|
id: number,
|
||||||
|
data: { name: string; color?: string; icon?: string }
|
||||||
|
): Promise<Category> {
|
||||||
|
return apiFetch<Category>(`/organizations/${orgId}/categories/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue