beetask/new-frontend/src/pages/TasksPage.tsx

478 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
import { CustomSelect } from '../components/ui/Select'
export const TasksPage: React.FC = () => {
const { activeOrg } = useAuth()
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([])
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<TaskStatus | 'ALL'>('ALL')
const [selectedProjectId, setSelectedProjectId] = useState<number | 'ALL'>('ALL')
const [onlyMyTasks, setOnlyMyTasks] = useState(false)
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalInitialStatus, setModalInitialStatus] = useState<TaskStatus>('NOT_STARTED')
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [actionError, setActionError] = useState<string | null>(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 (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Page 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.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-brand via-brand-strong to-accent bg-clip-text text-transparent">
Quadro de Tarefas
</h1>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25 shadow-xs">
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
{activeOrg?.name || 'Workspace'}
</span>
</div>
<p className="text-xs text-muted">
Planeje, execute e rastreie o tempo investido em cada entrega da equipe.
</p>
</div>
{/* View Switcher & New Task Button */}
<div className="flex items-center gap-2.5 flex-wrap">
{/* Refresh */}
<button
type="button"
onClick={loadData}
disabled={refreshing}
title="Recarregar tarefas"
className="p-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-panel disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
</button>
{/* Kanban / List Toggle */}
<div className="flex items-center p-1 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel">
<button
type="button"
onClick={() => setViewMode('kanban')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${
viewMode === 'kanban'
? 'bg-brand text-white shadow-md shadow-brand/20'
: 'text-muted hover:text-foreground'
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
<span>Kanban</span>
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all ${
viewMode === 'list'
? 'bg-brand text-white shadow-md shadow-brand/20'
: 'text-muted hover:text-foreground'
}`}
>
<ListIcon className="w-3.5 h-3.5" />
<span>Lista</span>
</button>
</div>
{/* New Task Button */}
<button
type="button"
onClick={() => handleOpenCreateModal('NOT_STARTED')}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0"
>
<Plus className="w-4 h-4" />
<span>Nova Tarefa</span>
</button>
</div>
</div>
{/* Error alert if any */}
{actionError && (
<div className="flex items-center justify-between p-4 rounded-2xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs shadow-panel backdrop-blur-md">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-400" />
<span>{actionError}</span>
</div>
<button
type="button"
onClick={() => setActionError(null)}
className="text-rose-400 hover:text-rose-200 text-xs font-bold"
>
Fechar
</button>
</div>
)}
{/* Quick KPI Bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex items-center gap-3.5 shadow-panel hover:-translate-y-0.5 transition-all">
<div className="w-10 h-10 rounded-xl bg-surface-sunken border border-border-soft flex items-center justify-center text-muted">
<Filter className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted font-medium">Total de Tarefas</p>
<p className="text-lg font-bold text-foreground">{tasks.length}</p>
</div>
</div>
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex items-center gap-3.5 shadow-panel hover:-translate-y-0.5 transition-all">
<div className="w-10 h-10 rounded-xl 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>
<p className="text-[11px] text-muted font-medium">Em Andamento</p>
<p className="text-lg font-bold text-teal-400">{inProgressCount}</p>
</div>
</div>
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex items-center gap-3.5 shadow-panel hover:-translate-y-0.5 transition-all">
<div className="w-10 h-10 rounded-xl 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>
<p className="text-[11px] text-muted font-medium">A Fazer</p>
<p className="text-lg font-bold text-foreground">{notStartedCount}</p>
</div>
</div>
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex items-center gap-3.5 shadow-panel hover:-translate-y-0.5 transition-all">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
<CheckCircle2 className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted font-medium">Concluídas</p>
<p className="text-lg font-bold text-emerald-400">{completedCount}</p>
</div>
</div>
</div>
{/* Filter and Search Bar */}
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex flex-col md:flex-row items-center justify-between gap-3 shadow-panel">
{/* Search Input */}
<div className="relative w-full md:w-80">
<Search className="w-4 h-4 text-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar por título, projeto, categoria..."
className="w-full pl-9 pr-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground placeholder-muted focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground text-xs"
>
×
</button>
)}
</div>
{/* Filters Group */}
<div className="flex items-center gap-2.5 flex-wrap w-full md:w-auto justify-end">
{/* Mine vs All toggle */}
<button
type="button"
onClick={() => setOnlyMyTasks(!onlyMyTasks)}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-medium border transition-all ${
onlyMyTasks
? 'bg-brand/15 text-brand border-brand/40 font-semibold shadow-xs'
: 'bg-surface-raised/80 border-border-soft text-muted hover:text-foreground'
}`}
>
{onlyMyTasks ? <UserCheck className="w-3.5 h-3.5" /> : <Users className="w-3.5 h-3.5" />}
<span>{onlyMyTasks ? 'Minhas Tarefas' : 'Todas'}</span>
</button>
{/* Project Filter */}
{projects.length > 0 && (
<div className="w-48">
<CustomSelect
value={selectedProjectId}
onChange={(val) => setSelectedProjectId(val)}
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
options={[
{ value: 'ALL', label: 'Todos os Projetos' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color || '#0f766e',
})),
]}
/>
</div>
)}
{/* Status Filter */}
<div className="w-44">
<CustomSelect
value={selectedStatus}
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
options={[
{ value: 'ALL', label: 'Todos os Status' },
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
]}
/>
</div>
</div>
</div>
{/* Main Content Area */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 py-8">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-96 rounded-2xl border border-border bg-surface-sunken/40 animate-pulse p-4 space-y-3"
>
<div className="h-4 bg-surface-raised rounded-md w-1/3" />
<div className="h-28 bg-surface-raised rounded-xl" />
<div className="h-28 bg-surface-raised rounded-xl" />
</div>
))}
</div>
) : filteredTasks.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">
<Sparkles className="w-7 h-7" />
</div>
<h3 className="text-base font-bold text-foreground mb-1">
Nenhuma tarefa encontrada
</h3>
<p className="text-xs text-muted max-w-sm mb-5 leading-relaxed">
{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!'}
</p>
<button
type="button"
onClick={() => 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"
>
<Plus className="w-4 h-4" />
<span>Criar Primeira Tarefa</span>
</button>
</div>
) : viewMode === 'kanban' ? (
<TaskKanbanView
tasks={filteredTasks}
onToggleTimer={handleToggleTimer}
onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal}
onAddTaskToStatus={handleOpenCreateModal}
activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds}
/>
) : (
<TaskListView
tasks={filteredTasks}
onToggleTimer={handleToggleTimer}
onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal}
activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds}
/>
)}
{/* Task Creation & Edit Modal */}
{activeOrgId && (
<CreateTaskModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingTask(null)
}}
onSubmit={handleModalSubmit}
organizationId={activeOrgId}
initialStatus={modalInitialStatus}
initialTask={editingTask}
/>
)}
</div>
)
}