+ {/* Top Row: Project & Quick Status Menu */}
+
+
+ {task.project ? (
+
+
+ {task.project.name}
+
+ ) : (
+
+ Geral
+
+ )}
+
+ {task.category && (
+
+
+ {task.category.name}
+
+ )}
+
+
+ {/* More Actions Menu */}
+
+
{
+ e.stopPropagation()
+ setMenuOpen(!menuOpen)
+ }}
+ className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
+ title="Ações"
+ >
+
+
+
+ {menuOpen && (
+ <>
+
{
+ e.stopPropagation()
+ setMenuOpen(false)
+ }}
+ />
+
+
+ Alterar Status
+
+ {(['NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED'] as TaskStatus[]).map((st) => (
+
{
+ e.stopPropagation()
+ onStatusChange(task, st)
+ setMenuOpen(false)
+ }}
+ className={`flex items-center justify-between w-full px-2 py-1.5 rounded-lg text-xs text-left transition-colors ${
+ task.status === st
+ ? 'bg-brand/15 text-brand font-medium'
+ : 'text-foreground/80 hover:bg-surface-sunken hover:text-foreground'
+ }`}
+ >
+ {statusConfig[st].label}
+ {task.status === st && }
+
+ ))}
+
+ {onEdit && (
+ <>
+
+
{
+ e.stopPropagation()
+ onEdit(task)
+ setMenuOpen(false)
+ }}
+ className="w-full px-2 py-1.5 rounded-lg text-xs text-left text-foreground/80 hover:bg-surface-sunken hover:text-foreground transition-colors"
+ >
+ Editar Detalhes
+
+ >
+ )}
+
+ >
+ )}
+
+
+
+ {/* Title */}
+
onEdit?.(task)}
+ className="font-semibold text-sm text-foreground hover:text-brand transition-colors cursor-pointer line-clamp-2 mb-1.5"
+ >
+ {task.title}
+
+
+ {/* Description preview */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Footer: User Avatar & Live Timer Controls */}
+
+ {/* Assignee / Creator */}
+
+ {task.user ? (
+
+ {task.user.name ? task.user.name.charAt(0) : task.user.email.charAt(0)}
+
+ ) : (
+
+
+
+ )}
+
+ {task.user?.name || task.user?.email?.split('@')[0] || 'Livre'}
+
+
+
+ {/* Timer button & display */}
+
+
+
+ {formatSecondsToTimer(totalSeconds)}
+
+
+
{
+ e.stopPropagation()
+ onToggleTimer(task)
+ }}
+ title={running ? 'Parar cronômetro' : 'Iniciar cronômetro'}
+ className={`p-1.5 rounded-lg transition-all duration-200 ${
+ running
+ ? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 ring-1 ring-rose-500/40'
+ : 'bg-brand/15 text-teal-300 hover:bg-brand/25 ring-1 ring-brand/30'
+ }`}
+ >
+ {running ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/new-frontend/src/components/tasks/TaskKanbanView.tsx b/new-frontend/src/components/tasks/TaskKanbanView.tsx
new file mode 100644
index 0000000..8ef58f1
--- /dev/null
+++ b/new-frontend/src/components/tasks/TaskKanbanView.tsx
@@ -0,0 +1,188 @@
+import React from 'react'
+import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban } from 'lucide-react'
+import type { Task, TaskStatus } from '../../types/task'
+import { TaskCard } from './TaskCard'
+import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
+
+interface TaskKanbanViewProps {
+ tasks: Task[]
+ onToggleTimer: (task: Task) => void
+ onStatusChange: (task: Task, newStatus: TaskStatus) => void
+ onEditTask: (task: Task) => void
+ onAddTaskToStatus: (status: TaskStatus) => void
+ activeTaskId?: number | null
+ activeTaskSeconds?: number
+}
+
+interface ColumnDef {
+ status: TaskStatus
+ label: string
+ icon: React.ElementType
+ colorClass: string
+ bgLightClass: string
+ borderColor: string
+}
+
+const COLUMNS: ColumnDef[] = [
+ {
+ status: 'NOT_STARTED',
+ label: 'A Fazer',
+ icon: CircleDot,
+ colorClass: 'text-zinc-400',
+ bgLightClass: 'bg-zinc-500/10',
+ borderColor: 'border-zinc-800',
+ },
+ {
+ status: 'IN_PROGRESS',
+ label: 'Em Andamento',
+ icon: Clock,
+ colorClass: 'text-teal-400',
+ bgLightClass: 'bg-teal-500/10',
+ borderColor: 'border-teal-500/20',
+ },
+ {
+ status: 'COMPLETED',
+ label: 'Concluído',
+ icon: CheckCircle2,
+ colorClass: 'text-emerald-400',
+ bgLightClass: 'bg-emerald-500/10',
+ borderColor: 'border-emerald-500/20',
+ },
+ {
+ status: 'CANCELED',
+ label: 'Cancelado',
+ icon: Ban,
+ colorClass: 'text-rose-400',
+ bgLightClass: 'bg-rose-500/10',
+ borderColor: 'border-rose-500/20',
+ },
+]
+
+export const TaskKanbanView: React.FC
= ({
+ tasks,
+ onToggleTimer,
+ onStatusChange,
+ onEditTask,
+ onAddTaskToStatus,
+ activeTaskId,
+ activeTaskSeconds,
+}) => {
+ const [dragOverColumn, setDragOverColumn] = React.useState(null)
+
+ const handleDragOver = (e: React.DragEvent, status: TaskStatus) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ if (dragOverColumn !== status) {
+ setDragOverColumn(status)
+ }
+ }
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault()
+ setDragOverColumn(null)
+ }
+
+ const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => {
+ e.preventDefault()
+ setDragOverColumn(null)
+ const taskIdStr = e.dataTransfer.getData('text/plain')
+ if (!taskIdStr) return
+
+ const taskId = Number(taskIdStr)
+ const targetTask = tasks.find((t) => t.id === taskId)
+ if (targetTask && targetTask.status !== targetStatus) {
+ onStatusChange(targetTask, targetStatus)
+ }
+ }
+
+ return (
+
+ {COLUMNS.map((col) => {
+ const columnTasks = tasks.filter((t) => t.status === col.status)
+ const colTotalSeconds = columnTasks.reduce(
+ (acc, t) => acc + calculateTaskTotalSeconds(t),
+ 0
+ )
+ const Icon = col.icon
+ const isOver = dragOverColumn === col.status
+
+ return (
+
handleDragOver(e, col.status)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, col.status)}
+ className={`flex flex-col rounded-2xl border transition-all duration-200 min-h-[480px] bg-surface-sunken/40 ${
+ isOver
+ ? 'border-brand ring-2 ring-brand/30 bg-brand/5'
+ : col.borderColor
+ }`}
+ >
+ {/* Column Header */}
+
+
+
+
+
+
+
+ {col.label}
+
+ {columnTasks.length}
+
+
+ {colTotalSeconds > 0 && (
+
+ {formatSecondsToHuman(colTotalSeconds)} investidos
+
+ )}
+
+
+
+
onAddTaskToStatus(col.status)}
+ title={`Adicionar tarefa em ${col.label}`}
+ className="w-7 h-7 rounded-lg border border-border bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
+ >
+
+
+
+
+ {/* Column Body / Cards List */}
+
+ {columnTasks.map((task) => (
+
+ ))}
+
+ {columnTasks.length === 0 && (
+
+
+
Nenhuma tarefa nesta coluna
+
onAddTaskToStatus(col.status)}
+ className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
+ >
+
+ Criar tarefa
+
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/new-frontend/src/components/tasks/TaskListView.tsx b/new-frontend/src/components/tasks/TaskListView.tsx
new file mode 100644
index 0000000..6a3453a
--- /dev/null
+++ b/new-frontend/src/components/tasks/TaskListView.tsx
@@ -0,0 +1,248 @@
+import React from 'react'
+import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react'
+import type { Task, TaskStatus } from '../../types/task'
+import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
+
+interface TaskListViewProps {
+ tasks: Task[]
+ onToggleTimer: (task: Task) => void
+ onStatusChange: (task: Task, newStatus: TaskStatus) => void
+ onEditTask: (task: Task) => void
+ activeTaskId?: number | null
+ activeTaskSeconds?: number
+}
+
+const statusBadge: Record = {
+ NOT_STARTED: {
+ label: 'A Fazer',
+ badgeClass: 'bg-zinc-800 text-zinc-300 border-zinc-700/60',
+ },
+ IN_PROGRESS: {
+ label: 'Em Andamento',
+ badgeClass: 'bg-teal-500/15 text-teal-300 border-teal-500/30',
+ },
+ COMPLETED: {
+ label: 'Concluído',
+ badgeClass: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
+ },
+ CANCELED: {
+ label: 'Cancelado',
+ badgeClass: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
+ },
+}
+
+export const TaskListView: React.FC = ({
+ tasks,
+ onToggleTimer,
+ onStatusChange,
+ onEditTask,
+ activeTaskId,
+ activeTaskSeconds,
+}) => {
+ const [activeMenuId, setActiveMenuId] = React.useState(null)
+
+ return (
+
+
+
+
+
+ Tarefa
+ Projeto
+ Categoria
+ Responsável
+ Status
+ Tempo Total
+ Ações
+
+
+
+ {tasks.map((task) => {
+ const isRunning =
+ activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task))
+ const totalSec =
+ activeTaskId === task.id && activeTaskSeconds !== undefined
+ ? activeTaskSeconds
+ : calculateTaskTotalSeconds(task)
+
+ return (
+
+ {/* Task Title & Description */}
+
+
+ onEditTask(task)}
+ className="font-medium text-foreground hover:text-brand cursor-pointer transition-colors truncate"
+ >
+ {task.title}
+
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
+
+ {/* Project */}
+
+ {task.project ? (
+
+
+ {task.project.name}
+
+ ) : (
+ —
+ )}
+
+
+ {/* Category */}
+
+ {task.category ? (
+
+
+ {task.category.name}
+
+ ) : (
+ —
+ )}
+
+
+ {/* Assignee */}
+
+ {task.user ? (
+
+
+ {task.user.name ? task.user.name.charAt(0) : task.user.email.charAt(0)}
+
+
+ {task.user.name || task.user.email.split('@')[0]}
+
+
+ ) : (
+ Não atribuído
+ )}
+
+
+ {/* Status Dropdown */}
+
+ onStatusChange(task, e.target.value as TaskStatus)}
+ aria-label="Status da tarefa"
+ className={`text-[11px] font-medium px-2 py-1 rounded-lg border cursor-pointer focus:outline-none focus:ring-1 focus:ring-brand ${statusBadge[task.status].badgeClass}`}
+ >
+ A Fazer
+ Em Andamento
+ Concluído
+ Cancelado
+
+
+
+ {/* Time Spent */}
+
+
+
+ {formatSecondsToTimer(totalSec)}
+
+
+
+ {/* Actions */}
+
+
+ {/* Timer button */}
+
onToggleTimer(task)}
+ title={isRunning ? 'Parar cronômetro' : 'Iniciar cronômetro'}
+ className={`p-1.5 rounded-lg transition-colors ${
+ isRunning
+ ? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
+ : 'bg-brand/15 text-teal-300 hover:bg-brand/25'
+ }`}
+ >
+ {isRunning ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Menu */}
+
+
setActiveMenuId(activeMenuId === task.id ? null : task.id)}
+ className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
+ >
+
+
+
+ {activeMenuId === task.id && (
+ <>
+
setActiveMenuId(null)}
+ />
+
+ {
+ onEditTask(task)
+ setActiveMenuId(null)
+ }}
+ className="w-full px-2.5 py-1.5 rounded-lg text-xs text-foreground hover:bg-surface-sunken transition-colors"
+ >
+ Editar Detalhes
+
+ {
+ onStatusChange(task, 'COMPLETED')
+ setActiveMenuId(null)
+ }}
+ className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs text-emerald-400 hover:bg-surface-sunken transition-colors"
+ >
+ Concluir
+
+
+
+ >
+ )}
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/new-frontend/src/contexts/TimerContext.tsx b/new-frontend/src/contexts/TimerContext.tsx
new file mode 100644
index 0000000..cb1cc57
--- /dev/null
+++ b/new-frontend/src/contexts/TimerContext.tsx
@@ -0,0 +1,96 @@
+import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
+import type { Task } from '../types/task'
+import { taskService } from '../services/taskService'
+import { isTaskRunning, calculateTaskTotalSeconds } from '../lib/timeUtils'
+
+interface TimerContextType {
+ activeTask: Task | null
+ activeSeconds: number
+ startTimer: (task: Task) => Promise
+ stopTimer: (task?: Task) => Promise
+ syncWithTasks: (tasks: Task[]) => void
+}
+
+const TimerContext = createContext(undefined)
+
+export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [activeTask, setActiveTask] = useState(null)
+ const [activeSeconds, setActiveSeconds] = useState(0)
+
+ // Sync state whenever tasks list updates
+ const syncWithTasks = useCallback((tasks: Task[]) => {
+ const running = tasks.find((t) => isTaskRunning(t))
+ if (running) {
+ setActiveTask(running)
+ setActiveSeconds(calculateTaskTotalSeconds(running))
+ } else {
+ setActiveTask(null)
+ setActiveSeconds(0)
+ }
+ }, [])
+
+ // Live timer interval
+ useEffect(() => {
+ if (!activeTask) return
+
+ const interval = setInterval(() => {
+ setActiveSeconds((prev) => prev + 1)
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [activeTask])
+
+ const startTimer = useCallback(
+ async (task: Task): Promise => {
+ // If there was an active task running different from this one, stop it first or handle
+ if (activeTask && activeTask.id !== task.id) {
+ try {
+ await taskService.stopTask(activeTask.id)
+ } catch (e) {
+ console.warn('Could not stop previously running task', e)
+ }
+ }
+
+ const updated = await taskService.startTask(task.id)
+ setActiveTask(updated)
+ setActiveSeconds(calculateTaskTotalSeconds(updated))
+ return updated
+ },
+ [activeTask]
+ )
+
+ const stopTimer = useCallback(
+ async (task?: Task): Promise => {
+ const targetId = task ? task.id : activeTask?.id
+ if (!targetId) return null
+
+ const updated = await taskService.stopTask(targetId)
+ setActiveTask(null)
+ setActiveSeconds(0)
+ return updated
+ },
+ [activeTask]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTimer = (): TimerContextType => {
+ const ctx = useContext(TimerContext)
+ if (!ctx) {
+ throw new Error('useTimer must be used within a TimerProvider')
+ }
+ return ctx
+}
diff --git a/new-frontend/src/lib/timeUtils.ts b/new-frontend/src/lib/timeUtils.ts
new file mode 100644
index 0000000..b435e13
--- /dev/null
+++ b/new-frontend/src/lib/timeUtils.ts
@@ -0,0 +1,49 @@
+import type { Task } from '../types/task'
+
+export function isTaskRunning(task: Task): boolean {
+ if (!task.history || task.history.length === 0) return false
+ return task.history.some((h) => !h.endAt)
+}
+
+export function calculateTaskTotalSeconds(task: Task): number {
+ if (!task.history || task.history.length === 0) return 0
+
+ let total = 0
+ for (const item of task.history) {
+ if (item.elapsedSeconds) {
+ total += item.elapsedSeconds
+ } else if (item.startAt && !item.endAt) {
+ const start = new Date(item.startAt).getTime()
+ const now = Date.now()
+ const diffSec = Math.max(0, Math.floor((now - start) / 1000))
+ total += diffSec
+ }
+ }
+ return total
+}
+
+export function formatSecondsToTimer(totalSeconds: number): string {
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+
+ const hh = hours.toString().padStart(2, '0')
+ const mm = minutes.toString().padStart(2, '0')
+ const ss = seconds.toString().padStart(2, '0')
+
+ return `${hh}:${mm}:${ss}`
+}
+
+export function formatSecondsToHuman(totalSeconds: number): string {
+ if (totalSeconds <= 0) return '0m'
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+
+ if (hours > 0 && minutes > 0) {
+ return `${hours}h ${minutes}m`
+ }
+ if (hours > 0) {
+ return `${hours}h`
+ }
+ return `${minutes}m`
+}
diff --git a/new-frontend/src/pages/TasksPage.tsx b/new-frontend/src/pages/TasksPage.tsx
new file mode 100644
index 0000000..d8baa42
--- /dev/null
+++ b/new-frontend/src/pages/TasksPage.tsx
@@ -0,0 +1,478 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react'
+import {
+ LayoutGrid,
+ List as ListIcon,
+ Plus,
+ Search,
+ Filter,
+ RefreshCw,
+ Folder,
+ UserCheck,
+ Users,
+ CheckCircle2,
+ Clock,
+ Sparkles,
+ AlertCircle,
+} from 'lucide-react'
+import { useAuth } from '../contexts/AuthContext'
+import { useTimer } from '../contexts/TimerContext'
+import { taskService } from '../services/taskService'
+import { projectService } from '../services/projectService'
+import type { Task, TaskStatus, Project } from '../types/task'
+import { TaskKanbanView } from '../components/tasks/TaskKanbanView'
+import { TaskListView } from '../components/tasks/TaskListView'
+import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
+
+export const TasksPage: React.FC = () => {
+ const { activeOrg } = useAuth()
+ const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
+
+ const [tasks, setTasks] = useState([])
+ const [projects, setProjects] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [refreshing, setRefreshing] = useState(false)
+ const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban')
+
+ // Filters
+ const [searchQuery, setSearchQuery] = useState('')
+ const [selectedStatus, setSelectedStatus] = useState('ALL')
+ const [selectedProjectId, setSelectedProjectId] = useState('ALL')
+ const [onlyMyTasks, setOnlyMyTasks] = useState(false)
+
+ // Modal state
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ const [modalInitialStatus, setModalInitialStatus] = useState('NOT_STARTED')
+ const [editingTask, setEditingTask] = useState(null)
+ const [actionError, setActionError] = useState(null)
+
+ const activeOrgId = activeOrg?.id
+
+ // Load tasks & projects for current organization
+ const loadData = useCallback(async () => {
+ if (!activeOrgId) return
+ try {
+ setRefreshing(true)
+ const [tasksRes, projsRes] = await Promise.all([
+ onlyMyTasks
+ ? taskService.listMyTasks({ organizationId: activeOrgId })
+ : taskService.listTasks({ organizationId: activeOrgId }),
+ projectService.listProjects(activeOrgId).catch(() => []),
+ ])
+
+ setTasks(tasksRes || [])
+ setProjects(projsRes || [])
+ syncWithTasks(tasksRes || [])
+ } catch (err) {
+ console.error('Failed to load tasks', err)
+ setActionError('Falha ao carregar tarefas. Tente atualizar a página.')
+ } finally {
+ setLoading(false)
+ setRefreshing(false)
+ }
+ }, [activeOrgId, onlyMyTasks, syncWithTasks])
+
+ useEffect(() => {
+ loadData()
+ }, [loadData])
+
+ // Filter tasks client-side for ultra-responsive search
+ const filteredTasks = useMemo(() => {
+ return tasks.filter((task) => {
+ // Status filter
+ if (selectedStatus !== 'ALL' && task.status !== selectedStatus) {
+ return false
+ }
+ // Project filter
+ if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) {
+ return false
+ }
+ // Search text filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase()
+ const matchesTitle = task.title.toLowerCase().includes(query)
+ const matchesDesc = task.description?.toLowerCase().includes(query)
+ const matchesProject = task.project?.name.toLowerCase().includes(query)
+ const matchesCategory = task.category?.name.toLowerCase().includes(query)
+ if (!matchesTitle && !matchesDesc && !matchesProject && !matchesCategory) {
+ return false
+ }
+ }
+ return true
+ })
+ }, [tasks, selectedStatus, selectedProjectId, searchQuery])
+
+ // Timer play/pause handler
+ const handleToggleTimer = async (task: Task) => {
+ setActionError(null)
+ const isCurrentlyRunning = activeTask?.id === task.id
+ try {
+ if (isCurrentlyRunning) {
+ const stopped = await stopTimer(task)
+ if (stopped) {
+ setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
+ }
+ } else {
+ const started = await startTimer(task)
+ setTasks((prev) => prev.map((t) => (t.id === started.id ? started : t)))
+ }
+ } catch (err: unknown) {
+ console.error('Timer action failed', err)
+ setActionError(
+ err instanceof Error ? err.message : 'Falha ao acionar cronômetro da tarefa.'
+ )
+ }
+ }
+
+ // Status change handler
+ const handleStatusChange = async (task: Task, newStatus: TaskStatus) => {
+ setActionError(null)
+ try {
+ // Optimistic update
+ setTasks((prev) =>
+ prev.map((t) => (t.id === task.id ? { ...t, status: newStatus } : t))
+ )
+ const updated = await taskService.updateStatus(task.id, newStatus)
+ setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
+ } catch (err: unknown) {
+ console.error('Failed to update status', err)
+ setActionError('Falha ao atualizar status da tarefa.')
+ loadData() // Revert to server state
+ }
+ }
+
+ // Create or Update task modal submit
+ const handleModalSubmit = async (taskData: {
+ title: string
+ description: string
+ status: TaskStatus
+ projectId?: number
+ categoryId?: number
+ assigneeUserId?: number
+ }) => {
+ if (!activeOrgId) return
+ setActionError(null)
+
+ if (editingTask) {
+ const updated = await taskService.updateTask(editingTask.id, {
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ projectId: taskData.projectId,
+ categoryId: taskData.categoryId,
+ assigneeUserId: taskData.assigneeUserId,
+ organizationId: activeOrgId,
+ })
+ setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
+ setEditingTask(null)
+ } else {
+ const created = await taskService.createTask({
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ projectId: taskData.projectId,
+ categoryId: taskData.categoryId,
+ assigneeUserId: taskData.assigneeUserId,
+ organizationId: activeOrgId,
+ })
+ setTasks((prev) => [created, ...prev])
+ }
+ }
+
+ const handleOpenCreateModal = (initialStatus: TaskStatus = 'NOT_STARTED') => {
+ setEditingTask(null)
+ setModalInitialStatus(initialStatus)
+ setIsModalOpen(true)
+ }
+
+ const handleOpenEditModal = (task: Task) => {
+ setEditingTask(task)
+ setIsModalOpen(true)
+ }
+
+ // Summary counts
+ const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length
+ const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length
+ const completedCount = tasks.filter((t) => t.status === 'COMPLETED').length
+
+ return (
+
+ {/* Page Header */}
+
+
+
+
+ Quadro de Tarefas
+
+
+ {activeOrg?.name || 'Workspace'}
+
+
+
+ Planeje, execute e rastreie o tempo investido em cada entrega da equipe.
+
+
+
+ {/* View Switcher & New Task Button */}
+
+ {/* Refresh */}
+
+
+
+
+ {/* Kanban / List Toggle */}
+
+ setViewMode('kanban')}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
+ viewMode === 'kanban'
+ ? 'bg-brand text-white shadow-xs'
+ : 'text-muted hover:text-foreground'
+ }`}
+ >
+
+ Kanban
+
+ setViewMode('list')}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
+ viewMode === 'list'
+ ? 'bg-brand text-white shadow-xs'
+ : 'text-muted hover:text-foreground'
+ }`}
+ >
+
+ Lista
+
+
+
+ {/* New Task Button */}
+
handleOpenCreateModal('NOT_STARTED')}
+ 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"
+ >
+
+ Nova Tarefa
+
+
+
+
+ {/* Error alert if any */}
+ {actionError && (
+
+
+
setActionError(null)}
+ className="text-rose-400 hover:text-rose-200 text-xs font-bold"
+ >
+ Fechar
+
+
+ )}
+
+ {/* Quick KPI Bar */}
+
+
+
+
+
+
+
Total de Tarefas
+
{tasks.length}
+
+
+
+
+
+
+
+
+
Em Andamento
+
{inProgressCount}
+
+
+
+
+
+
+
+
+
A Fazer
+
{notStartedCount}
+
+
+
+
+
+
+
+
+
Concluídas
+
{completedCount}
+
+
+
+
+ {/* Filter and Search Bar */}
+
+ {/* Search Input */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Buscar por título, projeto, categoria..."
+ 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"
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-foreground text-xs"
+ >
+ ×
+
+ )}
+
+
+ {/* Filters Group */}
+
+ {/* Mine vs All toggle */}
+
setOnlyMyTasks(!onlyMyTasks)}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium border transition-colors ${
+ onlyMyTasks
+ ? 'bg-brand/15 text-brand border-brand/40 font-semibold'
+ : 'bg-surface-raised border-border text-muted hover:text-foreground'
+ }`}
+ >
+ {onlyMyTasks ? : }
+ {onlyMyTasks ? 'Minhas Tarefas' : 'Todas'}
+
+
+ {/* Project Filter */}
+ {projects.length > 0 && (
+
+
+ setSelectedProjectId(
+ e.target.value === 'ALL' ? 'ALL' : Number(e.target.value)
+ )
+ }
+ className="pl-7 pr-3 py-1.5 rounded-xl border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
+ >
+ Todos os Projetos
+ {projects.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ )}
+
+ {/* Status Filter */}
+
setSelectedStatus(e.target.value as TaskStatus | 'ALL')}
+ className="px-3 py-1.5 rounded-xl border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
+ >
+ Todos os Status
+ A Fazer
+ Em Andamento
+ Concluído
+ Cancelado
+
+
+
+
+ {/* Main Content Area */}
+ {loading ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : filteredTasks.length === 0 ? (
+
+
+
+
+
+ Nenhuma tarefa encontrada
+
+
+ {searchQuery || selectedStatus !== 'ALL' || selectedProjectId !== 'ALL'
+ ? 'Nenhum resultado corresponde aos filtros selecionados. Tente limpar os filtros para ver mais.'
+ : 'Você ainda não possui tarefas criadas nesta organização. Comece agora mesmo organizando sua primeira entrega!'}
+
+
handleOpenCreateModal('NOT_STARTED')}
+ 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"
+ >
+
+ Criar Primeira Tarefa
+
+
+ ) : viewMode === 'kanban' ? (
+
+ ) : (
+
+ )}
+
+ {/* Task Creation & Edit Modal */}
+ {activeOrgId && (
+
{
+ setIsModalOpen(false)
+ setEditingTask(null)
+ }}
+ onSubmit={handleModalSubmit}
+ organizationId={activeOrgId}
+ initialStatus={modalInitialStatus}
+ initialTask={editingTask}
+ />
+ )}
+
+ )
+}
diff --git a/new-frontend/src/services/categoryService.ts b/new-frontend/src/services/categoryService.ts
new file mode 100644
index 0000000..bb256aa
--- /dev/null
+++ b/new-frontend/src/services/categoryService.ts
@@ -0,0 +1,15 @@
+import { apiFetch } from '../lib/api'
+import type { Category } from '../types/task'
+
+export const categoryService = {
+ async listCategories(orgId: number): Promise {
+ return apiFetch(`/organizations/${orgId}/categories`)
+ },
+
+ async createCategory(orgId: number, data: { name: string; color?: string; icon?: string }): Promise {
+ return apiFetch(`/organizations/${orgId}/categories`, {
+ method: 'POST',
+ body: data,
+ })
+ },
+}
diff --git a/new-frontend/src/services/organizationService.ts b/new-frontend/src/services/organizationService.ts
index 98531d4..8d03bd8 100644
--- a/new-frontend/src/services/organizationService.ts
+++ b/new-frontend/src/services/organizationService.ts
@@ -18,6 +18,13 @@ export type JoinRequestOutput = {
createdAt: string
}
+export type OrganizationMember = {
+ userId: number
+ userName: string
+ userEmail: string
+ userPhoto?: string
+}
+
export const organizationService = {
async create(name: string): Promise {
return apiFetch('/organizations', {
@@ -39,4 +46,9 @@ export const organizationService = {
async listMyRequests(): Promise {
return apiFetch('/organizations/my-requests')
},
+
+ async listMembers(organizationId: number): Promise {
+ return apiFetch(`/organizations/${organizationId}/members`)
+ },
}
+
diff --git a/new-frontend/src/services/projectService.ts b/new-frontend/src/services/projectService.ts
new file mode 100644
index 0000000..e09b8da
--- /dev/null
+++ b/new-frontend/src/services/projectService.ts
@@ -0,0 +1,15 @@
+import { apiFetch } from '../lib/api'
+import type { Project } from '../types/task'
+
+export const projectService = {
+ async listProjects(orgId: number): Promise {
+ return apiFetch(`/organizations/${orgId}/projects`)
+ },
+
+ async createProject(orgId: number, data: { name: string; color?: string; icon?: string }): Promise {
+ return apiFetch(`/organizations/${orgId}/projects`, {
+ method: 'POST',
+ body: data,
+ })
+ },
+}
diff --git a/new-frontend/src/services/taskService.ts b/new-frontend/src/services/taskService.ts
new file mode 100644
index 0000000..ff2afdd
--- /dev/null
+++ b/new-frontend/src/services/taskService.ts
@@ -0,0 +1,78 @@
+import { apiFetch } from '../lib/api'
+import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter } from '../types/task'
+
+function buildQueryParams(filter?: ListTasksFilter): string {
+ if (!filter) return ''
+ const params = new URLSearchParams()
+
+ if (filter.organizationId !== undefined) {
+ params.set('organizationId', String(filter.organizationId))
+ }
+ if (filter.text && filter.text.trim()) {
+ params.set('text', filter.text.trim())
+ }
+ if (filter.status && filter.status.length > 0) {
+ filter.status.forEach((st) => params.append('status', st))
+ }
+ if (filter.projectId && filter.projectId.length > 0) {
+ filter.projectId.forEach((pid) => params.append('projectId', String(pid)))
+ }
+ if (filter.categoryId && filter.categoryId.length > 0) {
+ filter.categoryId.forEach((cid) => params.append('categoryId', String(cid)))
+ }
+ if (filter.userId && filter.userId.length > 0) {
+ filter.userId.forEach((uid) => params.append('userId', String(uid)))
+ }
+
+ const query = params.toString()
+ return query ? `?${query}` : ''
+}
+
+export const taskService = {
+ async listTasks(filter?: ListTasksFilter): Promise {
+ const qs = buildQueryParams(filter)
+ return apiFetch(`/tasks${qs}`)
+ },
+
+ async listMyTasks(filter?: ListTasksFilter): Promise {
+ const qs = buildQueryParams(filter)
+ return apiFetch(`/tasks/mine${qs}`)
+ },
+
+ async getTask(id: number): Promise {
+ return apiFetch(`/tasks/${id}`)
+ },
+
+ async createTask(payload: CreateTaskPayload): Promise {
+ return apiFetch('/tasks', {
+ method: 'POST',
+ body: payload,
+ })
+ },
+
+ async updateTask(id: number, payload: Partial): Promise {
+ return apiFetch(`/tasks/${id}`, {
+ method: 'PUT',
+ body: payload,
+ })
+ },
+
+ async updateStatus(id: number, status: TaskStatus): Promise {
+ return apiFetch(`/tasks/${id}/status`, {
+ method: 'PATCH',
+ body: status,
+ })
+ },
+
+ async startTask(id: number): Promise {
+ return apiFetch(`/tasks/${id}/start`, {
+ method: 'PUT',
+ })
+ },
+
+ async stopTask(id: number): Promise {
+ return apiFetch(`/tasks/${id}/stop`, {
+ method: 'PUT',
+ })
+ },
+}
diff --git a/new-frontend/src/types/task.ts b/new-frontend/src/types/task.ts
new file mode 100644
index 0000000..57d2b84
--- /dev/null
+++ b/new-frontend/src/types/task.ts
@@ -0,0 +1,60 @@
+export type TaskStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED';
+
+export interface TaskHistoryItem {
+ id: number;
+ startAt: string;
+ endAt: string | null;
+ elapsedSeconds: number | null;
+}
+
+export interface Project {
+ id: number;
+ name: string;
+ color?: string;
+ icon?: string;
+}
+
+export interface Category {
+ id: number;
+ name: string;
+ color?: string;
+ icon?: string;
+}
+
+export interface TaskUser {
+ id: number;
+ name: string;
+ email: string;
+ photo?: string;
+}
+
+export interface Task {
+ id: number;
+ title: string;
+ description?: string;
+ status: TaskStatus;
+ project?: Project;
+ category?: Category;
+ user?: TaskUser;
+ finishedAt?: string;
+ history?: TaskHistoryItem[];
+}
+
+export interface CreateTaskPayload {
+ title: string;
+ description?: string;
+ status?: TaskStatus;
+ organizationId: number;
+ projectId?: number;
+ categoryId?: number;
+ assigneeUserId?: number;
+}
+
+export interface ListTasksFilter {
+ organizationId?: number;
+ text?: string;
+ status?: TaskStatus[];
+ projectId?: number[];
+ categoryId?: number[];
+ userId?: number[];
+}