From db463551e6feed35f89d548547276969951d4b56 Mon Sep 17 00:00:00 2001 From: Gabriel Santana de Menezes Date: Thu, 14 May 2026 22:12:05 -0400 Subject: [PATCH] feat: filtros de busca nas listagens de tasks (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 8 + .../repository/TaskRepositoryImpl.java | 30 ++++ .../controllers/task/ListMyTasksRequest.java | 16 ++ .../controllers/task/TaskController.java | 8 +- .../task/TaskControllerMapper.java | 5 + .../usecase/repository/TaskRepository.java | 2 + .../task/listall/ListMyTasksInput.java | 5 + .../task/listall/ListMyTasksUseCase.java | 7 +- frontend/src/components/TaskFilterBar.tsx | 101 +++++++++++++ frontend/src/pages/TaskBoardPage.tsx | 119 ++++++++------- frontend/src/pages/TaskListPage.tsx | 139 ++++++------------ frontend/src/services/taskService.ts | 9 +- 12 files changed, 295 insertions(+), 154 deletions(-) create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java create mode 100644 frontend/src/components/TaskFilterBar.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 62c4899..2b8553d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,14 @@ A new use case `usecase///` contains exactly: Then **wire it** via `@Produces` in the matching `frameworks/config/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//`: diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index 22fe044..da4d98a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -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 findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status) { + StringBuilder query = new StringBuilder("user.email = ?1"); + List 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 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 findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) { return find("user.email = ?1", email).list().stream() diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java new file mode 100644 index 0000000..111c1d7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java @@ -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; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java index 8de8895..3b11b84 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java @@ -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 listMyTasks() { + public List listMyTasks(@BeanParam ListMyTasksRequest request) { String email = securityIdentity.getPrincipal().getName(); - List tasks = listMyTasksUseCase.execute(email).stream().map(TaskControllerMapper::toCreateTaskResponse).toList(); + List 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; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index ded52f9..925cc76 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -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(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index 90c58b6..a921518 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -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 findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); + List findTasksByUserFiltered(String email, String text, Long projectId, TaskStatus status); List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java new file mode 100644 index 0000000..e597801 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java @@ -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) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java index b0934a6..a5119c5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksUseCase.java @@ -16,9 +16,10 @@ public class ListMyTasksUseCase { this.taskRepository = taskRepository; } - public List execute(String email) { - List tasks = taskRepository.findTasksByUser(email).stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); - LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), email); + public List execute(ListMyTasksInput input) { + List 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; } } diff --git a/frontend/src/components/TaskFilterBar.tsx b/frontend/src/components/TaskFilterBar.tsx new file mode 100644 index 0000000..9a6df2a --- /dev/null +++ b/frontend/src/components/TaskFilterBar.tsx @@ -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) => + onFiltersChange({ ...filters, ...partial }) + + return ( +
+
+ + 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" + /> +
+ + {projects.length > 0 && ( + + )} + +
+ + {(['ALL', ...TASK_STATUS_OPTIONS] as const).map((status) => ( + + ))} +
+ + {onRefresh && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/TaskBoardPage.tsx b/frontend/src/pages/TaskBoardPage.tsx index e837ad5..e5eed2c 100644 --- a/frontend/src/pages/TaskBoardPage.tsx +++ b/frontend/src/pages/TaskBoardPage.tsx @@ -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([]) const [isLoading, setIsLoading] = useState(true) + const [projects, setProjects] = useState([]) + const [filters, setFilters] = useState(DEFAULT_TASK_FILTERS) const navigate = useNavigate() + const { activeOrg } = useAuth() + const debounceRef = useRef | 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() {

- +
+ + + Lista + + + + Quadro + +
+ void loadTasks(filters)} + /> +
{COLUMNS.map((column) => ( @@ -135,13 +150,13 @@ export function TaskBoardPage() {

- {column.label} + {column.label}

{tasksByStatus(column.id).length}
-
@@ -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', )} >
-

+

{task.title}

{task.description && ( -

+

{task.description}

)}
- loadTasks(true)} /> + loadTasks(filters, true)} />
- + {task.project?.name || 'Geral'}
diff --git a/frontend/src/pages/TaskListPage.tsx b/frontend/src/pages/TaskListPage.tsx index 3aa6b86..8a0499d 100644 --- a/frontend/src/pages/TaskListPage.tsx +++ b/frontend/src/pages/TaskListPage.tsx @@ -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([]) const [isLoading, setIsLoading] = useState(true) - const [_errorMessage, setErrorMessage] = useState(null) + const [projects, setProjects] = useState([]) + const [filters, setFilters] = useState(DEFAULT_TASK_FILTERS) const navigate = useNavigate() - const [searchQuery, setSearchQuery] = useState('') - const [statusFilter, setStatusFilter] = useState('ALL') + const { activeOrg } = useAuth() + const debounceRef = useRef | 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 (
- {/* Header & Action */}

@@ -110,7 +105,6 @@ export function TaskListPage() {

- {/* View toggle */}
@@ -134,57 +128,17 @@ export function TaskListPage() {
+ void loadTasks(filters)} + /> - - {/* Toolbar */} -
-
- - 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" - /> -
- -
- - {(['ALL', ...TASK_STATUS_OPTIONS] as const).map( - (status) => ( - - ), - )} -
- - -
- - {/* Task List */}
- {filteredTasks.map((task, i) => { + {tasks.map((task, i) => { const config = getStatusConfig(task.status) const StatusIcon = config.icon @@ -209,21 +163,21 @@ export function TaskListPage() { {config.label}
- + #{task.id}
-

+

{task.title}

-

+

{task.description || 'Sem descrição informada.'}

- loadTasks(true)} /> + loadTasks(filters, true)} />
@@ -243,8 +197,7 @@ export function TaskListPage() {
- {/* Empty States */} - {!isLoading && filteredTasks.length === 0 && ( + {!isLoading && tasks.length === 0 && (