From 640af48a9cc2e08e4a8553b67bbabc757cbe395e Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Sat, 10 Oct 2026 22:36:25 -0400 Subject: [PATCH] feat(projects-categories): implement full Projects and Categories management pages with time analytics --- new-frontend/src/App.tsx | 6 +- new-frontend/src/pages/CategoriesPage.tsx | 446 +++++++++++++++++++ new-frontend/src/pages/ProjectsPage.tsx | 436 ++++++++++++++++++ new-frontend/src/services/categoryService.ts | 11 + 4 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 new-frontend/src/pages/CategoriesPage.tsx create mode 100644 new-frontend/src/pages/ProjectsPage.tsx diff --git a/new-frontend/src/App.tsx b/new-frontend/src/App.tsx index c73a424..b321a5e 100644 --- a/new-frontend/src/App.tsx +++ b/new-frontend/src/App.tsx @@ -8,6 +8,8 @@ import { AppShell } from './components/layout/AppShell' import { DashboardPage } from './pages/DashboardPage' import { OrganizationsPage } from './pages/OrganizationsPage' import { TasksPage } from './pages/TasksPage' +import { ProjectsPage } from './pages/ProjectsPage' +import { CategoriesPage } from './pages/CategoriesPage' export function App() { return ( @@ -32,8 +34,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/new-frontend/src/pages/CategoriesPage.tsx b/new-frontend/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..6f4bf32 --- /dev/null +++ b/new-frontend/src/pages/CategoriesPage.tsx @@ -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([]) + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [error, setError] = useState(null) + + // Modal + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingCategory, setEditingCategory] = useState(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 ( +
+ {/* Header */} +
+
+
+

+ Categorias & Tags +

+ + {activeOrg?.name || 'Workspace'} + +
+

+ Classifique suas tarefas por tipos de trabalho (Bug, Feature, Reunião, Design) para métricas precisas. +

+
+ +
+ + + +
+
+ + {error && ( +
+
+ + {error} +
+ +
+ )} + + {/* Search Bar */} +
+
+ + 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" + /> +
+
+ {filteredCategories.length} {filteredCategories.length === 1 ? 'categoria' : 'categorias'} +
+
+ + {/* Grid of Categories */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ) : filteredCategories.length === 0 ? ( +
+
+ +
+

+ Nenhuma categoria cadastrada +

+

+ {searchQuery + ? 'Nenhuma categoria encontrada com este termo.' + : 'Categorias permitem segmentar seus fluxos de trabalho e auditar onde o tempo é consumido.'} +

+ +
+ ) : ( +
+ {filteredCategories.map((category) => { + const stats = categoryStats.get(category.id) || { + totalTasks: 0, + totalSeconds: 0, + } + + return ( +
+
+
+
+
+ +
+
+

+ {category.name} +

+ + ID #{category.id} + +
+
+ + +
+ + {/* Metrics Bar */} +
+
+ +
+

Tarefas

+

+ {stats.totalTasks} associadas +

+
+
+
+ +
+

Tempo Total

+

+ {formatSecondsToHuman(stats.totalSeconds)} +

+
+
+
+
+ + {/* Footer Link */} +
+ + +
+
+ ) + })} +
+ )} + + {/* Modal Criar/Editar Categoria */} + {isModalOpen && ( +
+
setIsModalOpen(false)} + /> +
+
+
+
+ +
+
+

+ {editingCategory ? 'Editar Categoria' : 'Nova Categoria'} +

+

+ {editingCategory + ? 'Atualize o nome ou cor da tag.' + : 'Crie uma categoria para agrupar tipos de trabalho.'} +

+
+
+ +
+ +
+
+ + 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" + /> +
+ +
+ +
+ {COLOR_PRESETS.map((c) => ( +
+
+ +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/new-frontend/src/pages/ProjectsPage.tsx b/new-frontend/src/pages/ProjectsPage.tsx new file mode 100644 index 0000000..ff935f0 --- /dev/null +++ b/new-frontend/src/pages/ProjectsPage.tsx @@ -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([]) + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+
+
+

+ Projetos +

+ + {activeOrg?.name || 'Workspace'} + +
+

+ Organize suas entregas em iniciativas, acompanhe o progresso e o tempo dedicado. +

+
+ +
+ + + +
+
+ + {error && ( +
+
+ + {error} +
+ +
+ )} + + {/* Search Bar */} +
+
+ + 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" + /> +
+
+ {filteredProjects.length} {filteredProjects.length === 1 ? 'projeto' : 'projetos'} +
+
+ + {/* Grid of Projects */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : filteredProjects.length === 0 ? ( +
+
+ +
+

+ Nenhum projeto cadastrado +

+

+ {searchQuery + ? 'Nenhum projeto encontrado com este termo de busca.' + : 'Projetos ajudam a agrupar demandas por produtos, clientes ou sprints.'} +

+ +
+ ) : ( +
+ {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 ( +
+
+ {/* Top Bar with Icon & Color badge */} +
+
+
+ +
+
+

+ {project.name} +

+ + ID #{project.id} + +
+
+
+ + {/* Metrics Bar */} +
+
+ +
+

Tarefas

+

+ {stats.totalTasks} ({stats.completedTasks} concl.) +

+
+
+
+ +
+

Tempo Total

+

+ {formatSecondsToHuman(stats.totalSeconds)} +

+
+
+
+ + {/* Progress Bar */} +
+
+ Conclusão + {progress}% +
+
+
+
+
+
+ + {/* Footer Link */} +
+ + + {stats.completedTasks} entregues + + +
+
+ ) + })} +
+ )} + + {/* Modal Novo Projeto */} + {isModalOpen && ( +
+
setIsModalOpen(false)} + /> +
+
+
+
+ +
+
+

Novo Projeto

+

Crie um projeto para agrupar demandas.

+
+
+ +
+ +
+
+ + 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" + /> +
+ +
+ +
+ {COLOR_PRESETS.map((c) => ( +
+
+ +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/new-frontend/src/services/categoryService.ts b/new-frontend/src/services/categoryService.ts index bb256aa..2ea055a 100644 --- a/new-frontend/src/services/categoryService.ts +++ b/new-frontend/src/services/categoryService.ts @@ -12,4 +12,15 @@ export const categoryService = { body: data, }) }, + + async updateCategory( + orgId: number, + id: number, + data: { name: string; color?: string; icon?: string } + ): Promise { + return apiFetch(`/organizations/${orgId}/categories/${id}`, { + method: 'PUT', + body: data, + }) + }, }