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:
parent
7eda39021b
commit
db463551e6
12 changed files with 295 additions and 154 deletions
|
|
@ -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>/`:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
frontend/src/components/TaskFilterBar.tsx
Normal file
101
frontend/src/components/TaskFilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
<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"
|
||||
>
|
||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
{/* 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}
|
||||
<TaskFilterBar
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
projects={projects}
|
||||
isLoading={isLoading}
|
||||
onRefresh={() => void loadTasks(filters)}
|
||||
/>
|
||||
<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) => (
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue