feat(tasks): implement Kanban board, List view, real-time timer tracking, and task creation
This commit is contained in:
parent
c16992e049
commit
1806b08724
14 changed files with 1947 additions and 39 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
405
new-frontend/src/components/tasks/CreateTaskModal.tsx
Normal file
405
new-frontend/src/components/tasks/CreateTaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
new-frontend/src/components/tasks/TaskCard.tsx
Normal file
241
new-frontend/src/components/tasks/TaskCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
188
new-frontend/src/components/tasks/TaskKanbanView.tsx
Normal file
188
new-frontend/src/components/tasks/TaskKanbanView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
new-frontend/src/components/tasks/TaskListView.tsx
Normal file
248
new-frontend/src/components/tasks/TaskListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
new-frontend/src/contexts/TimerContext.tsx
Normal file
96
new-frontend/src/contexts/TimerContext.tsx
Normal 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
|
||||
}
|
||||
49
new-frontend/src/lib/timeUtils.ts
Normal file
49
new-frontend/src/lib/timeUtils.ts
Normal 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`
|
||||
}
|
||||
478
new-frontend/src/pages/TasksPage.tsx
Normal file
478
new-frontend/src/pages/TasksPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
new-frontend/src/services/categoryService.ts
Normal file
15
new-frontend/src/services/categoryService.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -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`)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
15
new-frontend/src/services/projectService.ts
Normal file
15
new-frontend/src/services/projectService.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
78
new-frontend/src/services/taskService.ts
Normal file
78
new-frontend/src/services/taskService.ts
Normal 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',
|
||||
})
|
||||
},
|
||||
}
|
||||
60
new-frontend/src/types/task.ts
Normal file
60
new-frontend/src/types/task.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in a new issue