feat(tasks): implement Kanban board, List view, real-time timer tracking, and task creation

This commit is contained in:
Gabriel Menezes 2026-10-10 22:32:55 -04:00
parent c16992e049
commit 1806b08724
14 changed files with 1947 additions and 39 deletions

View file

@ -1,16 +1,19 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { TimerProvider } from './contexts/TimerContext'
import { LandingPage } from './pages/LandingPage'
import { AuthPage } from './pages/AuthPage'
import { ProtectedRoute } from './components/layout/ProtectedRoute'
import { AppShell } from './components/layout/AppShell'
import { DashboardPage } from './pages/DashboardPage'
import { OrganizationsPage } from './pages/OrganizationsPage'
import { TasksPage } from './pages/TasksPage'
export function App() {
return (
<BrowserRouter>
<AuthProvider>
<TimerProvider>
<Routes>
{/* Rotas Públicas */}
<Route path="/" element={<LandingPage />} />
@ -27,8 +30,8 @@ export function App() {
>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/organizations" element={<OrganizationsPage />} />
<Route path="/tasks" element={<DashboardPage />} />
<Route path="/tasks/new" element={<DashboardPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/kanban" element={<TasksPage />} />
<Route path="/projects" element={<DashboardPage />} />
<Route path="/categories" element={<DashboardPage />} />
<Route path="/analytics" element={<DashboardPage />} />
@ -40,6 +43,7 @@ export function App() {
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TimerProvider>
</AuthProvider>
</BrowserRouter>
)

View file

@ -22,6 +22,9 @@ import {
Check,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useTimer } from '../../contexts/TimerContext'
import { formatSecondsToTimer } from '../../lib/timeUtils'
import { Square } from 'lucide-react'
interface HeaderProps {
onToggleMobileSidebar: () => void
@ -29,6 +32,7 @@ interface HeaderProps {
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
const { user, activeOrg, setActiveOrg, logout } = useAuth()
const { activeTask, activeSeconds, stopTimer } = useTimer()
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
const [showUserDropdown, setShowUserDropdown] = useState(false)
@ -170,24 +174,39 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
<div className="flex items-center gap-3">
{/* Active Timer Pill Banner */}
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-surface-muted border border-border-soft text-xs text-text-muted">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<Clock className="w-3.5 h-3.5 text-text-muted" />
<span className="font-mono font-semibold text-text-main">
Modo Foco Ativo
{activeTask ? (
<div className="hidden md:flex items-center gap-2.5 px-3 py-1.5 rounded-xl bg-teal-500/10 border border-teal-500/30 text-xs text-teal-300">
<div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />
<span className="font-mono font-bold text-teal-200">
{formatSecondsToTimer(activeSeconds)}
</span>
<Chip className="bg-accent-soft text-accent text-[10px] font-bold px-1.5 py-0.2">
<ChipLabel>v3.0</ChipLabel>
</Chip>
<span className="text-[11px] text-muted max-w-[120px] truncate">
{activeTask.title}
</span>
<button
type="button"
onClick={() => stopTimer(activeTask)}
title="Pausar cronômetro"
className="p-1 rounded-md bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 transition-colors ml-1"
>
<Square className="w-3 h-3 fill-rose-300" />
</button>
</div>
) : (
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-surface-muted border border-border-soft text-xs text-text-muted">
<Clock className="w-3.5 h-3.5 text-text-muted" />
<span className="text-[11px]">Nenhuma tarefa em execução</span>
</div>
)}
{/* Create Task Button */}
<Button
onClick={() => navigate('/tasks/new')}
onClick={() => navigate('/tasks')}
className="bg-brand hover:bg-brand-strong text-white font-bold text-xs px-3.5 py-2 rounded-xl shadow-md shadow-brand/20 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Nova Tarefa</span>
<span className="hidden sm:inline">Tarefas</span>
</Button>
{/* User Profile Menu Dropdown */}

View file

@ -0,0 +1,405 @@
import React, { useState, useEffect } from 'react'
import { X, Folder, Tag, User, Loader2, Sparkles } from 'lucide-react'
import type { Task, TaskStatus, Project, Category } from '../../types/task'
import type { OrganizationMember } from '../../services/organizationService'
import { projectService } from '../../services/projectService'
import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService'
interface CreateTaskModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (taskData: {
title: string
description: string
status: TaskStatus
projectId?: number
categoryId?: number
assigneeUserId?: number
}) => Promise<void>
organizationId: number
initialStatus?: TaskStatus
initialTask?: Task | null
}
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
isOpen,
onClose,
onSubmit,
organizationId,
initialStatus = 'NOT_STARTED',
initialTask = null,
}) => {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [status, setStatus] = useState<TaskStatus>(initialStatus)
const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined)
const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [members, setMembers] = useState<OrganizationMember[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Quick inline creation states
const [newProjectName, setNewProjectName] = useState('')
const [showAddProject, setShowAddProject] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showAddCategory, setShowAddCategory] = useState(false)
// Populate data when modal opens
useEffect(() => {
if (!isOpen) return
if (initialTask) {
setTitle(initialTask.title)
setDescription(initialTask.description || '')
setStatus(initialTask.status)
setProjectId(initialTask.project?.id)
setCategoryId(initialTask.category?.id)
setAssigneeUserId(initialTask.user?.id)
} else {
setTitle('')
setDescription('')
setStatus(initialStatus)
setProjectId(undefined)
setCategoryId(undefined)
setAssigneeUserId(undefined)
}
setError(null)
setShowAddProject(false)
setShowAddCategory(false)
// Load projects, categories, members
const loadMetadata = async () => {
setLoading(true)
try {
const [projRes, catRes, memRes] = await Promise.allSettled([
projectService.listProjects(organizationId),
categoryService.listCategories(organizationId),
organizationService.listMembers(organizationId),
])
if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
if (memRes.status === 'fulfilled') setMembers(memRes.value || [])
} catch (err) {
console.error('Error fetching metadata', err)
} finally {
setLoading(false)
}
}
loadMetadata()
}, [isOpen, organizationId, initialStatus, initialTask])
if (!isOpen) return null
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!newProjectName.trim()) return
try {
const created = await projectService.createProject(organizationId, {
name: newProjectName.trim(),
color: '#0f766e',
})
setProjects((prev) => [...prev, created])
setProjectId(created.id)
setNewProjectName('')
setShowAddProject(false)
} catch (err: unknown) {
console.error(err)
setError('Falha ao criar projeto.')
}
}
const handleCreateCategory = async (e: React.FormEvent) => {
e.preventDefault()
if (!newCategoryName.trim()) return
try {
const created = await categoryService.createCategory(organizationId, {
name: newCategoryName.trim(),
color: '#d97706',
})
setCategories((prev) => [...prev, created])
setCategoryId(created.id)
setNewCategoryName('')
setShowAddCategory(false)
} catch (err: unknown) {
console.error(err)
setError('Falha ao criar categoria.')
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
setError('O título da tarefa é obrigatório.')
return
}
setSubmitting(true)
setError(null)
try {
await onSubmit({
title: title.trim(),
description: description.trim(),
status,
projectId,
categoryId,
assigneeUserId,
})
onClose()
} catch (err: unknown) {
console.error(err)
setError(err instanceof Error ? err.message : 'Erro ao salvar tarefa.')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/70 backdrop-blur-xs transition-opacity"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className="relative w-full max-w-xl rounded-2xl border border-border bg-surface-raised shadow-2xl z-10 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<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">
{initialTask ? 'Editar Tarefa' : 'Nova Tarefa'}
</h2>
<p className="text-xs text-muted">
{initialTask
? 'Atualize as informações da demanda.'
: 'Preencha os campos para cadastrar no quadro de atividades.'}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
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>
{/* Body Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-4">
{error && (
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
{error}
</div>
)}
{/* Title */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Título da Tarefa <span className="text-brand">*</span>
</label>
<input
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Implementar autenticação via OAuth2..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-sm text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all"
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Descrição detalhada
</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Descreva o escopo, critérios de aceite ou observações importantes..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all resize-none"
/>
</div>
{/* Project & Category row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Project */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-brand" />
Projeto
</label>
<button
type="button"
onClick={() => setShowAddProject(!showAddProject)}
className="text-[11px] text-brand hover:underline font-medium"
>
{showAddProject ? 'Cancelar' : '+ Novo'}
</button>
</div>
{showAddProject ? (
<div className="flex items-center gap-1.5 mb-1">
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Nome do projeto..."
className="flex-1 px-2.5 py-1.5 rounded-lg border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
<button
type="button"
onClick={handleCreateProject}
className="px-2.5 py-1.5 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-hover"
>
Salvar
</button>
</div>
) : (
<select
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : undefined)}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
>
<option value="">Sem projeto</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
</div>
{/* Category */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-accent" />
Categoria
</label>
<button
type="button"
onClick={() => setShowAddCategory(!showAddCategory)}
className="text-[11px] text-accent hover:underline font-medium"
>
{showAddCategory ? 'Cancelar' : '+ Nova'}
</button>
</div>
{showAddCategory ? (
<div className="flex items-center gap-1.5 mb-1">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="Nome da categoria..."
className="flex-1 px-2.5 py-1.5 rounded-lg border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-2.5 py-1.5 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover"
>
Salvar
</button>
</div>
) : (
<select
value={categoryId ?? ''}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
>
<option value="">Sem categoria</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
</div>
</div>
{/* Assignee & Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Assignee */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5 flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-muted" />
Responsável
</label>
<select
value={assigneeUserId ?? ''}
onChange={(e) =>
setAssigneeUserId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
>
<option value="">Atribuir a mim mesmo / Sem responsável</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.userName} ({m.userEmail})
</option>
))}
</select>
</div>
{/* Status */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Status Inicial
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TaskStatus)}
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
>
<option value="NOT_STARTED">A Fazer (NOT_STARTED)</option>
<option value="IN_PROGRESS">Em Andamento (IN_PROGRESS)</option>
<option value="COMPLETED">Concluído (COMPLETED)</option>
<option value="CANCELED">Cancelado (CANCELED)</option>
</select>
</div>
</div>
{/* Footer Controls */}
<div className="pt-4 border-t border-border flex items-center justify-end gap-2.5">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || loading}
className="px-5 py-2 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-xs transition-all flex items-center gap-2 disabled:opacity-50"
>
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{initialTask ? 'Salvar Alterações' : 'Criar Tarefa'}
</button>
</div>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,241 @@
import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreVertical, CheckCircle2, CircleDot } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
interface TaskCardProps {
task: Task
onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEdit?: (task: Task) => void
isTrackingThis?: boolean
currentSeconds?: number
}
const statusConfig: Record<TaskStatus, { label: string; badgeClass: string }> = {
NOT_STARTED: {
label: 'Não Iniciado',
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 TaskCard: React.FC<TaskCardProps> = ({
task,
onToggleTimer,
onStatusChange,
onEdit,
isTrackingThis,
currentSeconds,
}) => {
const running = isTrackingThis ?? isTaskRunning(task)
const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task)
const [menuOpen, setMenuOpen] = React.useState(false)
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', String(task.id))
e.dataTransfer.effectAllowed = 'move'
}
return (
<div
draggable
onDragStart={handleDragStart}
className={`group relative rounded-xl border bg-surface p-4 transition-all duration-200 cursor-grab active:cursor-grabbing hover:shadow-lg hover:border-zinc-700 ${
running
? 'border-brand/60 shadow-[0_0_20px_rgba(15,118,110,0.18)] ring-1 ring-brand/40'
: 'border-border shadow-xs'
}`}
>
{/* Top Row: Project & Quick Status Menu */}
<div className="flex items-center justify-between gap-2 mb-2.5">
<div className="flex items-center gap-1.5 flex-wrap">
{task.project ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium border"
style={{
backgroundColor: task.project.color ? `${task.project.color}15` : 'rgba(255,255,255,0.05)',
color: task.project.color || '#94a3b8',
borderColor: task.project.color ? `${task.project.color}35` : 'rgba(255,255,255,0.1)',
}}
>
<Folder className="w-3 h-3" />
{task.project.name}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] text-muted border border-border bg-surface-raised">
Geral
</span>
)}
{task.category && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium border"
style={{
backgroundColor: task.category.color ? `${task.category.color}15` : 'rgba(217,119,6,0.1)',
color: task.category.color || '#d97706',
borderColor: task.category.color ? `${task.category.color}35` : 'rgba(217,119,6,0.2)',
}}
>
<Tag className="w-3 h-3" />
{task.category.name}
</span>
)}
</div>
{/* More Actions Menu */}
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setMenuOpen(!menuOpen)
}}
className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
title="Ações"
>
<MoreVertical className="w-4 h-4" />
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
}}
/>
<div className="absolute right-0 top-6 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1.5 shadow-xl">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted">
Alterar Status
</div>
{(['NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED'] as TaskStatus[]).map((st) => (
<button
key={st}
type="button"
onClick={(e) => {
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'
}`}
>
<span>{statusConfig[st].label}</span>
{task.status === st && <CheckCircle2 className="w-3.5 h-3.5 text-brand" />}
</button>
))}
{onEdit && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
onClick={(e) => {
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
</button>
</>
)}
</div>
</>
)}
</div>
</div>
{/* Title */}
<h3
onClick={() => onEdit?.(task)}
className="font-semibold text-sm text-foreground hover:text-brand transition-colors cursor-pointer line-clamp-2 mb-1.5"
>
{task.title}
</h3>
{/* Description preview */}
{task.description && (
<p className="text-xs text-muted line-clamp-2 mb-3 leading-relaxed">
{task.description}
</p>
)}
{/* Footer: User Avatar & Live Timer Controls */}
<div className="flex items-center justify-between pt-3 border-t border-border/60 mt-2">
{/* Assignee / Creator */}
<div className="flex items-center gap-1.5">
{task.user ? (
<div
className="w-6 h-6 rounded-full bg-brand/20 border border-brand/30 flex items-center justify-center text-[10px] font-bold text-brand uppercase"
title={task.user.name || task.user.email}
>
{task.user.name ? task.user.name.charAt(0) : task.user.email.charAt(0)}
</div>
) : (
<div
className="w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-[10px] text-muted"
title="Sem responsável"
>
<CircleDot className="w-3 h-3" />
</div>
)}
<span className="text-[11px] text-muted max-w-[90px] truncate">
{task.user?.name || task.user?.email?.split('@')[0] || 'Livre'}
</span>
</div>
{/* Timer button & display */}
<div className="flex items-center gap-2">
<div
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-mono transition-colors ${
running
? 'bg-brand/20 text-teal-300 font-semibold'
: 'bg-surface-sunken text-muted'
}`}
>
<Clock className={`w-3 h-3 ${running ? 'animate-pulse text-brand' : ''}`} />
<span>{formatSecondsToTimer(totalSeconds)}</span>
</div>
<button
type="button"
onClick={(e) => {
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 ? (
<Square className="w-3.5 h-3.5 fill-rose-300" />
) : (
<Play className="w-3.5 h-3.5 fill-teal-300" />
)}
</button>
</div>
</div>
</div>
)
}

View file

@ -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<TaskKanbanViewProps> = ({
tasks,
onToggleTimer,
onStatusChange,
onEditTask,
onAddTaskToStatus,
activeTaskId,
activeTaskSeconds,
}) => {
const [dragOverColumn, setDragOverColumn] = React.useState<TaskStatus | null>(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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-start pb-6">
{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 (
<div
key={col.status}
onDragOver={(e) => 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 */}
<div className="flex items-center justify-between p-3.5 border-b border-border/80">
<div className="flex items-center gap-2">
<div
className={`w-7 h-7 rounded-lg ${col.bgLightClass} flex items-center justify-center ${col.colorClass}`}
>
<Icon className="w-4 h-4" />
</div>
<div>
<h3 className="font-semibold text-xs tracking-tight text-foreground flex items-center gap-2">
{col.label}
<span className="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-surface-raised border border-border text-muted">
{columnTasks.length}
</span>
</h3>
{colTotalSeconds > 0 && (
<span className="text-[10px] text-muted font-mono">
{formatSecondsToHuman(colTotalSeconds)} investidos
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => 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"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Column Body / Cards List */}
<div className="flex-1 p-3 flex flex-col gap-3">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onToggleTimer={onToggleTimer}
onStatusChange={onStatusChange}
onEdit={onEditTask}
isTrackingThis={activeTaskId === task.id}
currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined}
/>
))}
{columnTasks.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center rounded-xl border border-dashed border-border/60">
<AlertCircle className="w-6 h-6 text-muted/60 mb-2" />
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
<button
type="button"
onClick={() => onAddTaskToStatus(col.status)}
className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Criar tarefa
</button>
</div>
)}
</div>
</div>
)
})}
</div>
)
}

View file

@ -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<TaskStatus, { label: string; badgeClass: string }> = {
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<TaskListViewProps> = ({
tasks,
onToggleTimer,
onStatusChange,
onEditTask,
activeTaskId,
activeTaskSeconds,
}) => {
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
return (
<div className="rounded-2xl border border-border bg-surface overflow-hidden shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border bg-surface-sunken/60 text-muted font-semibold uppercase tracking-wider text-[11px]">
<th className="py-3 px-4">Tarefa</th>
<th className="py-3 px-4">Projeto</th>
<th className="py-3 px-4">Categoria</th>
<th className="py-3 px-4">Responsável</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Tempo Total</th>
<th className="py-3 px-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tasks.map((task) => {
const isRunning =
activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task))
const totalSec =
activeTaskId === task.id && activeTaskSeconds !== undefined
? activeTaskSeconds
: calculateTaskTotalSeconds(task)
return (
<tr
key={task.id}
className={`group hover:bg-surface-raised/50 transition-colors ${
isRunning ? 'bg-brand/5' : ''
}`}
>
{/* Task Title & Description */}
<td className="py-3.5 px-4 max-w-xs">
<div className="flex flex-col">
<span
onClick={() => onEditTask(task)}
className="font-medium text-foreground hover:text-brand cursor-pointer transition-colors truncate"
>
{task.title}
</span>
{task.description && (
<span className="text-muted text-[11px] truncate max-w-sm">
{task.description}
</span>
)}
</div>
</td>
{/* Project */}
<td className="py-3.5 px-4 whitespace-nowrap">
{task.project ? (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md font-medium text-[11px] border"
style={{
backgroundColor: task.project.color ? `${task.project.color}15` : 'rgba(255,255,255,0.05)',
color: task.project.color || '#94a3b8',
borderColor: task.project.color ? `${task.project.color}35` : 'rgba(255,255,255,0.1)',
}}
>
<Folder className="w-3 h-3" />
{task.project.name}
</span>
) : (
<span className="text-muted text-[11px]">—</span>
)}
</td>
{/* Category */}
<td className="py-3.5 px-4 whitespace-nowrap">
{task.category ? (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md font-medium text-[11px] border"
style={{
backgroundColor: task.category.color ? `${task.category.color}15` : 'rgba(217,119,6,0.1)',
color: task.category.color || '#d97706',
borderColor: task.category.color ? `${task.category.color}35` : 'rgba(217,119,6,0.2)',
}}
>
<Tag className="w-3 h-3" />
{task.category.name}
</span>
) : (
<span className="text-muted text-[11px]">—</span>
)}
</td>
{/* Assignee */}
<td className="py-3.5 px-4 whitespace-nowrap">
{task.user ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-brand/20 text-brand border border-brand/30 flex items-center justify-center text-[10px] font-bold uppercase">
{task.user.name ? task.user.name.charAt(0) : task.user.email.charAt(0)}
</div>
<span className="text-foreground/90 font-medium">
{task.user.name || task.user.email.split('@')[0]}
</span>
</div>
) : (
<span className="text-muted text-[11px]">Não atribuído</span>
)}
</td>
{/* Status Dropdown */}
<td className="py-3.5 px-4 whitespace-nowrap">
<select
value={task.status}
onChange={(e) => 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}`}
>
<option value="NOT_STARTED" className="bg-surface-raised text-foreground">A Fazer</option>
<option value="IN_PROGRESS" className="bg-surface-raised text-foreground">Em Andamento</option>
<option value="COMPLETED" className="bg-surface-raised text-foreground">Concluído</option>
<option value="CANCELED" className="bg-surface-raised text-foreground">Cancelado</option>
</select>
</td>
{/* Time Spent */}
<td className="py-3.5 px-4 whitespace-nowrap">
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] ${
isRunning
? 'bg-brand/20 text-teal-300 font-bold'
: 'bg-surface-sunken text-muted'
}`}
>
<Clock className={`w-3.5 h-3.5 ${isRunning ? 'animate-pulse text-brand' : ''}`} />
<span>{formatSecondsToTimer(totalSec)}</span>
</div>
</td>
{/* Actions */}
<td className="py-3.5 px-4 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1.5">
{/* Timer button */}
<button
type="button"
onClick={() => 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 ? (
<Square className="w-3.5 h-3.5 fill-rose-300" />
) : (
<Play className="w-3.5 h-3.5 fill-teal-300" />
)}
</button>
{/* Menu */}
<div className="relative">
<button
type="button"
onClick={() => setActiveMenuId(activeMenuId === task.id ? null : task.id)}
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{activeMenuId === task.id && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setActiveMenuId(null)}
/>
<div className="absolute right-0 top-7 z-50 w-36 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
<button
type="button"
onClick={() => {
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
</button>
<button
type="button"
onClick={() => {
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"
>
<span>Concluir</span>
<CheckCircle2 className="w-3.5 h-3.5" />
</button>
</div>
</>
)}
</div>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View file

@ -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<Task>
stopTimer: (task?: Task) => Promise<Task | null>
syncWithTasks: (tasks: Task[]) => void
}
const TimerContext = createContext<TimerContextType | undefined>(undefined)
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeTask, setActiveTask] = useState<Task | null>(null)
const [activeSeconds, setActiveSeconds] = useState<number>(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<Task> => {
// 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<Task | null> => {
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 (
<TimerContext.Provider
value={{
activeTask,
activeSeconds,
startTimer,
stopTimer,
syncWithTasks,
}}
>
{children}
</TimerContext.Provider>
)
}
export const useTimer = (): TimerContextType => {
const ctx = useContext(TimerContext)
if (!ctx) {
throw new Error('useTimer must be used within a TimerProvider')
}
return ctx
}

View file

@ -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`
}

View file

@ -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<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">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Quadro de Tarefas
</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">
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 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>
{/* Kanban / List Toggle */}
<div className="flex items-center p-1 rounded-xl border border-border bg-surface">
<button
type="button"
onClick={() => 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'
}`}
>
<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 py-1.5 rounded-lg text-xs font-semibold transition-all ${
viewMode === 'list'
? 'bg-brand text-white shadow-xs'
: '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 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>Nova Tarefa</span>
</button>
</div>
</div>
{/* Error alert if any */}
{actionError && (
<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>{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">
<div className="p-3.5 rounded-xl border border-border bg-surface flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center text-zinc-300">
<Filter className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted">Total de Tarefas</p>
<p className="text-base font-bold text-foreground">{tasks.length}</p>
</div>
</div>
<div className="p-3.5 rounded-xl border border-border bg-surface flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-400">
<Clock className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted">Em Andamento</p>
<p className="text-base font-bold text-teal-400">{inProgressCount}</p>
</div>
</div>
<div className="p-3.5 rounded-xl border border-border bg-surface flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center text-muted">
<Sparkles className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted">A Fazer</p>
<p className="text-base font-bold text-foreground">{notStartedCount}</p>
</div>
</div>
<div className="p-3.5 rounded-xl border border-border bg-surface flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
<CheckCircle2 className="w-4 h-4" />
</div>
<div>
<p className="text-[11px] text-muted">Concluídas</p>
<p className="text-base font-bold text-emerald-400">{completedCount}</p>
</div>
</div>
</div>
{/* Filter and Search Bar */}
<div className="p-3.5 rounded-2xl border border-border bg-surface flex flex-col md:flex-row items-center justify-between gap-3 shadow-xs">
{/* Search Input */}
<div className="relative w-full md: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 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 && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2.5 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 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 ? <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="relative">
<select
value={selectedProjectId}
onChange={(e) =>
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"
>
<option value="ALL">Todos os Projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Folder className="w-3.5 h-3.5 text-brand absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
)}
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => 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"
>
<option value="ALL">Todos os Status</option>
<option value="NOT_STARTED">A Fazer</option>
<option value="IN_PROGRESS">Em Andamento</option>
<option value="COMPLETED">Concluído</option>
<option value="CANCELED">Cancelado</option>
</select>
</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>
)
}

View file

@ -0,0 +1,15 @@
import { apiFetch } from '../lib/api'
import type { Category } from '../types/task'
export const categoryService = {
async listCategories(orgId: number): Promise<Category[]> {
return apiFetch<Category[]>(`/organizations/${orgId}/categories`)
},
async createCategory(orgId: number, data: { name: string; color?: string; icon?: string }): Promise<Category> {
return apiFetch<Category>(`/organizations/${orgId}/categories`, {
method: 'POST',
body: data,
})
},
}

View file

@ -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<CreateOrgResponse> {
return apiFetch<CreateOrgResponse>('/organizations', {
@ -39,4 +46,9 @@ export const organizationService = {
async listMyRequests(): Promise<JoinRequestOutput[]> {
return apiFetch<JoinRequestOutput[]>('/organizations/my-requests')
},
async listMembers(organizationId: number): Promise<OrganizationMember[]> {
return apiFetch<OrganizationMember[]>(`/organizations/${organizationId}/members`)
},
}

View file

@ -0,0 +1,15 @@
import { apiFetch } from '../lib/api'
import type { Project } from '../types/task'
export const projectService = {
async listProjects(orgId: number): Promise<Project[]> {
return apiFetch<Project[]>(`/organizations/${orgId}/projects`)
},
async createProject(orgId: number, data: { name: string; color?: string; icon?: string }): Promise<Project> {
return apiFetch<Project>(`/organizations/${orgId}/projects`, {
method: 'POST',
body: data,
})
},
}

View file

@ -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<Task[]> {
const qs = buildQueryParams(filter)
return apiFetch<Task[]>(`/tasks${qs}`)
},
async listMyTasks(filter?: ListTasksFilter): Promise<Task[]> {
const qs = buildQueryParams(filter)
return apiFetch<Task[]>(`/tasks/mine${qs}`)
},
async getTask(id: number): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}`)
},
async createTask(payload: CreateTaskPayload): Promise<Task> {
return apiFetch<Task>('/tasks', {
method: 'POST',
body: payload,
})
},
async updateTask(id: number, payload: Partial<CreateTaskPayload>): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}`, {
method: 'PUT',
body: payload,
})
},
async updateStatus(id: number, status: TaskStatus): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}/status`, {
method: 'PATCH',
body: status,
})
},
async startTask(id: number): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}/start`, {
method: 'PUT',
})
},
async stopTask(id: number): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}/stop`, {
method: 'PUT',
})
},
}

View file

@ -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[];
}