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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import { TimerProvider } from './contexts/TimerContext'
|
||||||
import { LandingPage } from './pages/LandingPage'
|
import { LandingPage } from './pages/LandingPage'
|
||||||
import { AuthPage } from './pages/AuthPage'
|
import { AuthPage } from './pages/AuthPage'
|
||||||
import { ProtectedRoute } from './components/layout/ProtectedRoute'
|
import { ProtectedRoute } from './components/layout/ProtectedRoute'
|
||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
import { DashboardPage } from './pages/DashboardPage'
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
import { OrganizationsPage } from './pages/OrganizationsPage'
|
import { OrganizationsPage } from './pages/OrganizationsPage'
|
||||||
|
import { TasksPage } from './pages/TasksPage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<TimerProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Rotas Públicas */}
|
{/* Rotas Públicas */}
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
|
@ -27,8 +30,8 @@ export function App() {
|
||||||
>
|
>
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/organizations" element={<OrganizationsPage />} />
|
<Route path="/organizations" element={<OrganizationsPage />} />
|
||||||
<Route path="/tasks" element={<DashboardPage />} />
|
<Route path="/tasks" element={<TasksPage />} />
|
||||||
<Route path="/tasks/new" element={<DashboardPage />} />
|
<Route path="/kanban" element={<TasksPage />} />
|
||||||
<Route path="/projects" element={<DashboardPage />} />
|
<Route path="/projects" element={<DashboardPage />} />
|
||||||
<Route path="/categories" element={<DashboardPage />} />
|
<Route path="/categories" element={<DashboardPage />} />
|
||||||
<Route path="/analytics" element={<DashboardPage />} />
|
<Route path="/analytics" element={<DashboardPage />} />
|
||||||
|
|
@ -40,6 +43,7 @@ export function App() {
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</TimerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import {
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { useTimer } from '../../contexts/TimerContext'
|
||||||
|
import { formatSecondsToTimer } from '../../lib/timeUtils'
|
||||||
|
import { Square } from 'lucide-react'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleMobileSidebar: () => void
|
onToggleMobileSidebar: () => void
|
||||||
|
|
@ -29,6 +32,7 @@ interface HeaderProps {
|
||||||
|
|
||||||
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
||||||
|
const { activeTask, activeSeconds, stopTimer } = useTimer()
|
||||||
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
|
const [showOrgDropdown, setShowOrgDropdown] = useState(false)
|
||||||
const [showUserDropdown, setShowUserDropdown] = 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 */}
|
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Active Timer Pill Banner */}
|
{/* 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">
|
{activeTask ? (
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
<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">
|
||||||
<Clock className="w-3.5 h-3.5 text-text-muted" />
|
<div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
|
||||||
<span className="font-mono font-semibold text-text-main">
|
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />
|
||||||
Modo Foco Ativo
|
<span className="font-mono font-bold text-teal-200">
|
||||||
|
{formatSecondsToTimer(activeSeconds)}
|
||||||
</span>
|
</span>
|
||||||
<Chip className="bg-accent-soft text-accent text-[10px] font-bold px-1.5 py-0.2">
|
<span className="text-[11px] text-muted max-w-[120px] truncate">
|
||||||
<ChipLabel>v3.0</ChipLabel>
|
{activeTask.title}
|
||||||
</Chip>
|
</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>
|
||||||
|
) : (
|
||||||
|
<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 */}
|
{/* Create Task Button */}
|
||||||
<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"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Nova Tarefa</span>
|
<span className="hidden sm:inline">Tarefas</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* User Profile Menu Dropdown */}
|
{/* 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
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrganizationMember = {
|
||||||
|
userId: number
|
||||||
|
userName: string
|
||||||
|
userEmail: string
|
||||||
|
userPhoto?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const organizationService = {
|
export const organizationService = {
|
||||||
async create(name: string): Promise<CreateOrgResponse> {
|
async create(name: string): Promise<CreateOrgResponse> {
|
||||||
return apiFetch<CreateOrgResponse>('/organizations', {
|
return apiFetch<CreateOrgResponse>('/organizations', {
|
||||||
|
|
@ -39,4 +46,9 @@ export const organizationService = {
|
||||||
async listMyRequests(): Promise<JoinRequestOutput[]> {
|
async listMyRequests(): Promise<JoinRequestOutput[]> {
|
||||||
return apiFetch<JoinRequestOutput[]>('/organizations/my-requests')
|
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