feat: filtros de busca nas listagens de tasks (#26)

Backend: adiciona ListMyTasksInput, ListMyTasksRequest (@BeanParam) com
parâmetros text, projectId e status; novo método findTasksByUserFiltered
no repositório com query dinâmica via Panache; mapper atualizado;
CLAUDE.md documenta regra de DTO Request para 3+ parâmetros.

Frontend: componente reutilizável TaskFilterBar (texto, projeto, status)
integrado em TaskListPage e TaskBoardPage; getMyTasks() aceita filtros
repassados como query params ao backend.

closes #26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Santana de Menezes 2026-05-14 22:12:05 -04:00
parent 7eda39021b
commit db463551e6
12 changed files with 295 additions and 154 deletions

View file

@ -56,6 +56,14 @@ A new use case `usecase/<feature>/<action>/` contains exactly:
Then **wire it** via `@Produces` in the matching `frameworks/config/<Feature>UseCaseConfig.java`. Use cases are NOT annotated with CDI scopes themselves — the producer is what makes them injectable.
### Query parameter DTOs
When a controller endpoint receives **3 or more query/request parameters**, create a dedicated DTO class in the controller package and a mapping method in the `XxxControllerMapper`:
- For **query parameters** (GET): use a plain Java class with public fields annotated with `@QueryParam`, and receive it via `@BeanParam XxxRequest` in the controller method.
- For **request body** parameters (POST/PUT): use a `record XxxRequest(...)` as usual.
- Add `toXxxInput(XxxRequest request, ...)` to `XxxControllerMapper` — never inline the mapping in the controller.
### Controller anatomy
`interfaceadapters/controllers/<feature>/`:

View file

@ -1,5 +1,6 @@
package net.beetechgroup.beetask.frameworks.persistence.repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.time.LocalDateTime;
@ -50,6 +51,35 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return tasks;
}
@Override
public List<Task> findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status) {
StringBuilder query = new StringBuilder("user.email = ?1");
List<Object> params = new ArrayList<>();
params.add(email);
int idx = 2;
if (Objects.nonNull(text) && !text.isBlank()) {
query.append(" and (lower(title) like ?").append(idx).append(" or lower(description) like ?").append(idx + 1).append(")");
String pattern = "%" + text.toLowerCase() + "%";
params.add(pattern);
params.add(pattern);
idx += 2;
}
if (Objects.nonNull(projectId)) {
query.append(" and project.id = ?").append(idx);
params.add(projectId);
idx++;
}
if (Objects.nonNull(status)) {
query.append(" and status = ?").append(idx);
params.add(status);
}
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList();
LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectId=%s, status=%s)", tasks.size(), email, text, projectId, status);
return tasks;
}
@Override
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
return find("user.email = ?1", email).list().stream()

View file

@ -0,0 +1,16 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import jakarta.ws.rs.QueryParam;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public class ListMyTasksRequest {
@QueryParam("text")
public String text;
@QueryParam("projectId")
public Long projectId;
@QueryParam("status")
public TaskStatus status;
}

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import jakarta.inject.Inject;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
@ -144,11 +145,12 @@ public class TaskController {
@GET
@Path("/mine")
@Operation(summary = "List my tasks", description = "Lists all tasks belonging to the authenticated user")
@Operation(summary = "List my tasks", description = "Lists all tasks belonging to the authenticated user, with optional filters")
@APIResponse(responseCode = "200", description = "Tasks listed successfully")
public List<CreateTaskResponse> listMyTasks() {
public List<CreateTaskResponse> listMyTasks(@BeanParam ListMyTasksRequest request) {
String email = securityIdentity.getPrincipal().getName();
List<CreateTaskResponse> tasks = listMyTasksUseCase.execute(email).stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
List<CreateTaskResponse> tasks = listMyTasksUseCase.execute(TaskControllerMapper.toListMyTasksInput(request, email))
.stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
LOGGER.infof("Listed %d tasks for user %s", tasks.size(), email);
return tasks;
}

View file

@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.Objects;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput;
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
@ -10,6 +11,10 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper {
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
return new ListMyTasksInput(email, request.text, request.projectId, request.status);
}
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
return new CreateTaskInput(
request.title(),

View file

@ -4,6 +4,7 @@ import java.util.List;
import java.time.LocalDateTime;
import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public interface TaskRepository {
Task saveTask(Task task);
@ -12,6 +13,7 @@ public interface TaskRepository {
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email);
List<Task> findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status);
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);

View file

@ -0,0 +1,5 @@
package net.beetechgroup.beetask.usecase.task.listall;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record ListMyTasksInput(String email, String text, Long projectId, TaskStatus status) {}

View file

@ -16,9 +16,10 @@ public class ListMyTasksUseCase {
this.taskRepository = taskRepository;
}
public List<CreateTaskOutput> execute(String email) {
List<CreateTaskOutput> tasks = taskRepository.findTasksByUser(email).stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), email);
public List<CreateTaskOutput> execute(ListMyTasksInput input) {
List<CreateTaskOutput> tasks = taskRepository.findTasksByUserFiltered(input.email(), input.text(), input.projectId(), input.status())
.stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email());
return tasks;
}
}

View file

@ -0,0 +1,101 @@
import { Filter, RefreshCw, Search } from 'lucide-react'
import { cn } from '../lib/utils'
import type { TaskStatus } from '../types/task'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
import type { Project } from '../services/projectService'
export interface TaskFilters {
searchQuery: string
statusFilter: TaskStatus | 'ALL'
projectId: number | 'ALL'
}
export const DEFAULT_TASK_FILTERS: TaskFilters = {
searchQuery: '',
statusFilter: 'ALL',
projectId: 'ALL',
}
interface TaskFilterBarProps {
filters: TaskFilters
onFiltersChange: (filters: TaskFilters) => void
projects: Project[]
isLoading?: boolean
onRefresh?: () => void
}
export function TaskFilterBar({
filters,
onFiltersChange,
projects,
isLoading,
onRefresh,
}: TaskFilterBarProps) {
const update = (partial: Partial<TaskFilters>) =>
onFiltersChange({ ...filters, ...partial })
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
size={18}
/>
<input
type="text"
placeholder="Buscar por título ou descrição..."
value={filters.searchQuery}
onChange={(e) => update({ searchQuery: e.target.value })}
className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
/>
</div>
{projects.length > 0 && (
<select
value={filters.projectId}
onChange={(e) =>
update({
projectId: e.target.value === 'ALL' ? 'ALL' : Number(e.target.value),
})
}
className="h-11 rounded-xl border border-border-soft bg-app-bg px-3 text-sm text-text-main outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
>
<option value="ALL">Todos os projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
<Filter size={18} className="mr-2 shrink-0 text-text-muted" />
{(['ALL', ...TASK_STATUS_OPTIONS] as const).map((status) => (
<button
key={status}
onClick={() => update({ statusFilter: status })}
className={cn(
'whitespace-nowrap rounded-lg px-4 py-2 text-xs font-bold transition-all',
filters.statusFilter === status
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
>
{status === 'ALL' ? 'Todos' : TASK_STATUS_LABELS[status]}
</button>
))}
</div>
{onRefresh && (
<button
onClick={onRefresh}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
title="Recarregar"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
)}
</div>
)
}

View file

@ -4,69 +4,71 @@ import {
AlertCircle,
CheckCircle2,
Clock,
List,
MoreVertical,
Plus,
RefreshCw,
Tag,
XCircle,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService'
import { cn } from '../lib/utils'
import type { TaskResponse, TaskStatus } from '../types/task'
import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar'
import { useAuth } from '../contexts/AuthContext'
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
{
id: 'NOT_STARTED',
label: 'Pendente',
icon: AlertCircle,
color: 'text-text-muted',
},
{
id: 'IN_PROGRESS',
label: 'Em Andamento',
icon: Clock,
color: 'text-warning',
},
{
id: 'COMPLETED',
label: 'Concluída',
icon: CheckCircle2,
color: 'text-success',
},
{
id: 'CANCELED',
label: 'Cancelada',
icon: XCircle,
color: 'text-danger',
},
{ id: 'NOT_STARTED', label: 'Pendente', icon: AlertCircle, color: 'text-text-muted' },
{ id: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' },
{ id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' },
{ id: 'CANCELED', label: 'Cancelada', icon: XCircle, color: 'text-danger' },
]
import { TaskTimer } from '../components/TaskTimer'
export function TaskBoardPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate()
const { activeOrg } = useAuth()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadTasks = async (silent = false) => {
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
if (!silent) setIsLoading(true)
try {
const response = await taskService.getMyTasks()
const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined,
status: currentFilters.statusFilter !== 'ALL' ? currentFilters.statusFilter : undefined,
})
setTasks(response)
} catch (error) {
console.error('Failed to load tasks', error)
console.error('Erro ao carregar tarefas', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
void loadTasks()
void loadTasks(filters)
if (activeOrg) {
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
}
}, [])
const handleFiltersChange = (newFilters: TaskFilters) => {
setFilters(newFilters)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
void loadTasks(newFilters)
}, 300)
}
const onDragEnd = async (result: DropResult) => {
const { destination, source, draggableId } = result
@ -81,18 +83,16 @@ export function TaskBoardPage() {
const taskId = Number.parseInt(draggableId)
const newStatus = destination.droppableId as TaskStatus
// Optimistic update
const updatedTasks = tasks.map((t) =>
t.id === taskId ? { ...t, status: newStatus } : t,
)
setTasks(updatedTasks)
try {
// Use the new generic status update endpoint
await taskService.updateTaskStatus(taskId, newStatus)
} catch (error) {
console.error('Failed to update task status', error)
void loadTasks(true) // Revert on error
console.error('Erro ao atualizar status da tarefa', error)
void loadTasks(filters, true)
}
}
@ -111,12 +111,19 @@ export function TaskBoardPage() {
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => void loadTasks()}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-surface border border-border-soft text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
<div className="flex items-center rounded-xl border border-border-soft bg-surface p-1 shadow-sm">
<Link
to="/tasks"
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold text-text-muted transition-all hover:bg-surface-muted hover:text-text-main"
>
<List size={14} />
Lista
</Link>
<span className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-2 text-xs font-bold text-white">
<AlertCircle size={14} />
Quadro
</span>
</div>
<Link
to="/new"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-strong active:scale-95"
@ -127,6 +134,14 @@ export function TaskBoardPage() {
</div>
</div>
<TaskFilterBar
filters={filters}
onFiltersChange={handleFiltersChange}
projects={projects}
isLoading={isLoading}
onRefresh={() => void loadTasks(filters)}
/>
<DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{COLUMNS.map((column) => (
@ -141,7 +156,7 @@ export function TaskBoardPage() {
{tasksByStatus(column.id).length}
</span>
</div>
<button className="text-text-muted hover:text-text-main transition-colors">
<button className="text-text-muted transition-colors hover:text-text-main">
<MoreVertical size={16} />
</button>
</div>
@ -154,7 +169,7 @@ export function TaskBoardPage() {
className={cn(
'flex min-h-[500px] flex-col gap-3 rounded-[2rem] border-2 border-transparent p-3 transition-colors',
snapshot.isDraggingOver
? 'bg-brand/5 border-dashed border-brand/20'
? 'border-dashed border-brand/20 bg-brand/5'
: 'bg-surface/50',
)}
>
@ -172,26 +187,28 @@ export function TaskBoardPage() {
onClick={() => navigate(`/edit/${task.id}`)}
className={cn(
'group cursor-pointer rounded-2xl border border-border-soft bg-surface p-4 shadow-sm transition-all',
snapshot.isDragging ? 'shadow-2xl ring-2 ring-brand/20' : 'hover:border-brand/30',
snapshot.isDragging
? 'shadow-2xl ring-2 ring-brand/20'
: 'hover:border-brand/30',
)}
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-bold leading-tight text-text-main group-hover:text-brand transition-colors">
<h3 className="text-sm font-bold leading-tight text-text-main transition-colors group-hover:text-brand">
{task.title}
</h3>
</div>
{task.description && (
<p className="mt-2 text-xs text-text-muted line-clamp-2">
<p className="mt-2 line-clamp-2 text-xs text-text-muted">
{task.description}
</p>
)}
<div className="mt-4 space-y-4">
<TaskTimer task={task} onUpdate={() => loadTasks(true)} />
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
<Tag size={12} className="text-brand" />
<AlertCircle size={12} className="text-brand" />
{task.project?.name || 'Geral'}
</div>
<span className="font-mono text-[10px] font-bold text-text-muted/50">

View file

@ -5,21 +5,24 @@ import {
CheckCircle2,
Clock,
Columns,
Filter,
List,
Plus,
RefreshCw,
Search,
Tag,
XCircle,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService'
import { cn } from '../lib/utils'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
import type { TaskResponse, TaskStatus } from '../types/task'
import type { TaskResponse } from '../types/task'
import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar'
import { useAuth } from '../contexts/AuthContext'
function getStatusConfig(status: TaskResponse['status']) {
switch (status) {
@ -50,56 +53,48 @@ function getStatusConfig(status: TaskResponse['status']) {
}
}
import { TaskTimer } from '../components/TaskTimer'
export function TaskListPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [_errorMessage, setErrorMessage] = useState<string | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'ALL'>('ALL')
const { activeOrg } = useAuth()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadTasks = async (silent = false) => {
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
if (!silent) setIsLoading(true)
setErrorMessage(null)
try {
const response = await taskService.getMyTasks()
const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined,
status: currentFilters.statusFilter !== 'ALL' ? currentFilters.statusFilter : undefined,
})
setTasks(response)
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: 'Não foi possível carregar as tarefas.',
)
console.error('Erro ao carregar tarefas', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
void loadTasks()
void loadTasks(filters)
if (activeOrg) {
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
}
}, [])
const filteredTasks = useMemo(() => {
return tasks.filter((task) => {
const matchesSearch =
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.project?.name?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'ALL' || task.status === statusFilter
return matchesSearch && matchesStatus
})
}, [tasks, searchQuery, statusFilter])
const handleFiltersChange = (newFilters: TaskFilters) => {
setFilters(newFilters)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
void loadTasks(newFilters)
}, 300)
}
return (
<div className="space-y-8 pb-12">
{/* Header & Action */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-black tracking-tight text-text-main">
@ -110,7 +105,6 @@ export function TaskListPage() {
</p>
</div>
<div className="flex items-center gap-3">
{/* View toggle */}
<div className="flex items-center rounded-xl border border-border-soft bg-surface p-1 shadow-sm">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-2 text-xs font-bold text-white">
<List size={14} />
@ -134,57 +128,17 @@ export function TaskListPage() {
</div>
</div>
<TaskFilterBar
filters={filters}
onFiltersChange={handleFiltersChange}
projects={projects}
isLoading={isLoading}
onRefresh={() => void loadTasks(filters)}
/>
{/* Toolbar */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
size={18}
/>
<input
type="text"
placeholder="Buscar tarefas por título, projeto ou descrição..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
<Filter size={18} className="mr-2 text-text-muted shrink-0" />
{(['ALL', ...TASK_STATUS_OPTIONS] as const).map(
(status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={cn(
'whitespace-nowrap rounded-lg px-4 py-2 text-xs font-bold transition-all',
statusFilter === status
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
>
{status === 'ALL' ? 'Todos' : TASK_STATUS_LABELS[status]}
</button>
),
)}
</div>
<button
onClick={() => void loadTasks()}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
title="Recarregar"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
{/* Task List */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<AnimatePresence mode="popLayout">
{filteredTasks.map((task, i) => {
{tasks.map((task, i) => {
const config = getStatusConfig(task.status)
const StatusIcon = config.icon
@ -209,21 +163,21 @@ export function TaskListPage() {
<StatusIcon size={12} />
{config.label}
</div>
<span className="font-mono text-[10px] font-bold text-text-muted group-hover:text-brand transition-colors">
<span className="font-mono text-[10px] font-bold text-text-muted transition-colors group-hover:text-brand">
#{task.id}
</span>
</div>
<h3 className="mt-4 text-lg font-bold leading-tight text-text-main group-hover:text-brand transition-colors text-ellipsis overflow-hidden">
<h3 className="mt-4 overflow-hidden text-ellipsis text-lg font-bold leading-tight text-text-main transition-colors group-hover:text-brand">
{task.title}
</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-text-muted line-clamp-3">
<p className="mt-2 flex-1 line-clamp-3 text-sm leading-relaxed text-text-muted">
{task.description || 'Sem descrição informada.'}
</p>
<div className="mt-6 flex flex-col gap-4">
<TaskTimer task={task} onUpdate={() => loadTasks(true)} />
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
<div className="flex flex-wrap items-center gap-3 border-t border-border-soft/50 pt-4">
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
@ -243,8 +197,7 @@ export function TaskListPage() {
</AnimatePresence>
</div>
{/* Empty States */}
{!isLoading && filteredTasks.length === 0 && (
{!isLoading && tasks.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -261,10 +214,7 @@ export function TaskListPage() {
procura.
</p>
<button
onClick={() => {
setSearchQuery('')
setStatusFilter('ALL')
}}
onClick={() => handleFiltersChange(DEFAULT_TASK_FILTERS)}
className="mt-6 text-sm font-bold text-brand hover:underline"
>
Limpar todos os filtros
@ -272,7 +222,6 @@ export function TaskListPage() {
</motion.div>
)}
{/* Loading State */}
{isLoading && tasks.length === 0 && (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (

View file

@ -6,8 +6,13 @@ export const taskService = {
return apiFetch<TaskResponse[]>('/tasks')
},
async getMyTasks(): Promise<TaskResponse[]> {
return apiFetch<TaskResponse[]>('/tasks/mine')
async getMyTasks(filters?: { text?: string; projectId?: number; status?: TaskStatus }): Promise<TaskResponse[]> {
const params = new URLSearchParams()
if (filters?.text) params.set('text', filters.text)
if (filters?.projectId) params.set('projectId', String(filters.projectId))
if (filters?.status) params.set('status', filters.status)
const query = params.toString()
return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`)
},
async createTask(task: { title: string; description?: string; projectId?: number | null; status?: TaskStatus }): Promise<TaskResponse> {