Add multi-select task filters for projects and statuses

This commit is contained in:
Gabriel Santana de Menezes 2026-05-17 19:22:44 -04:00
parent 2d14ae480d
commit ea7ffaaffd
12 changed files with 190 additions and 59 deletions

View file

@ -53,7 +53,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
} }
@Override @Override
public List<Task> findTasksByUserFiltered(String email, String text, Long projectId, public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) { List<TaskStatus> statuses, List<Long> categoryIds) {
StringBuilder query = new StringBuilder("user.email = ?1"); StringBuilder query = new StringBuilder("user.email = ?1");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -67,9 +67,9 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
params.add(pattern); params.add(pattern);
idx += 2; idx += 2;
} }
if (Objects.nonNull(projectId)) { if (Objects.nonNull(projectIds) && !projectIds.isEmpty()) {
query.append(" and project.id = ?").append(idx); query.append(" and project.id in ?").append(idx);
params.add(projectId); params.add(projectIds);
idx++; idx++;
} }
if (Objects.nonNull(statuses) && !statuses.isEmpty()) { if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
@ -84,8 +84,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
} }
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList(); 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, statuses=%s, categoryIds=%s)", LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s)",
tasks.size(), email, text, projectId, statuses, categoryIds); tasks.size(), email, text, projectIds, statuses, categoryIds);
return tasks; return tasks;
} }

View file

@ -11,7 +11,7 @@ public class ListMyTasksRequest {
public String text; public String text;
@QueryParam("projectId") @QueryParam("projectId")
public Long projectId; public List<Long> projectIds;
@QueryParam("status") @QueryParam("status")
public List<TaskStatus> statuses; public List<TaskStatus> statuses;

View file

@ -12,7 +12,7 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper { public class TaskControllerMapper {
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) { public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
return new ListMyTasksInput(email, request.text, request.projectId, request.statuses, request.categoryIds); return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
} }
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) { public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {

View file

@ -13,7 +13,7 @@ public interface TaskRepository {
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email); List<Task> findTasksByUser(String email);
List<Task> findTasksByUserFiltered(String email, String text, Long projectId, List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds); List<TaskStatus> statuses, List<Long> categoryIds);
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);

View file

@ -4,5 +4,5 @@ import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.entities.task.TaskStatus;
public record ListMyTasksInput(String email, String text, Long projectId, public record ListMyTasksInput(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) {} List<TaskStatus> statuses, List<Long> categoryIds) {}

View file

@ -18,7 +18,7 @@ public class ListMyTasksUseCase {
public List<CreateTaskOutput> execute(ListMyTasksInput input) { public List<CreateTaskOutput> execute(ListMyTasksInput input) {
List<CreateTaskOutput> tasks = taskRepository.findTasksByUserFiltered( List<CreateTaskOutput> tasks = taskRepository.findTasksByUserFiltered(
input.email(), input.text(), input.projectId(), input.statuses(), input.categoryIds()) input.email(), input.text(), input.projectIds(), input.statuses(), input.categoryIds())
.stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); .stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email()); LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email());
return tasks; return tasks;

View file

@ -0,0 +1,132 @@
import { Check, ChevronDown, X } from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '../lib/utils'
export interface MultiSelectDropdownOption<T extends string | number> {
value: T
label: string
icon?: ReactNode
}
interface MultiSelectDropdownProps<T extends string | number> {
label: string
allLabel: string
options: MultiSelectDropdownOption<T>[]
selected: T[]
onChange: (selected: T[]) => void
className?: string
}
export function MultiSelectDropdown<T extends string | number>({
label,
allLabel,
options,
selected,
onChange,
className,
}: MultiSelectDropdownProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [isOpen])
const toggleValue = (value: T) => {
if (selected.includes(value)) {
onChange(selected.filter((item) => item !== value))
return
}
onChange([...selected, value])
}
const getSummaryLabel = () => {
if (selected.length === 0) return allLabel
if (selected.length === 1) {
return options.find((option) => option.value === selected[0])?.label ?? allLabel
}
return `${selected.length} selecionados`
}
return (
<div ref={containerRef} className={cn('relative min-w-0', className)}>
<span className="mb-1.5 block text-[10px] font-black uppercase tracking-widest text-text-muted">
{label}
</span>
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className={cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-border-soft bg-app-bg px-3 text-left text-sm text-text-main outline-none transition-all',
'focus:border-brand focus:ring-2 focus:ring-brand/10',
isOpen && 'border-brand ring-2 ring-brand/10',
)}
>
<span className="truncate">{getSummaryLabel()}</span>
<ChevronDown
size={16}
className={cn('shrink-0 text-text-muted transition-transform', isOpen && 'rotate-180')}
/>
</button>
{isOpen && (
<div className="absolute z-20 mt-2 w-full rounded-2xl border border-border-soft bg-surface p-2 shadow-xl">
<button
type="button"
onClick={() => onChange([])}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-sm transition-colors',
selected.length === 0
? 'bg-brand/10 font-bold text-brand'
: 'text-text-muted hover:bg-app-bg hover:text-text-main',
)}
>
<span className="truncate">{allLabel}</span>
{selected.length > 0 && <X size={14} className="shrink-0" />}
</button>
<div className="mt-2 max-h-64 overflow-y-auto">
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<button
key={String(option.value)}
type="button"
onClick={() => toggleValue(option.value)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-sm transition-colors',
isSelected
? 'bg-brand text-white'
: 'text-text-main hover:bg-app-bg',
)}
>
<span className="flex min-w-0 items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</span>
<Check
size={14}
className={cn('shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
/>
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -1,24 +1,11 @@
import { Filter, RefreshCw, Search } from 'lucide-react' import { Filter, RefreshCw, Search } from 'lucide-react'
import type { TaskStatus } from '../types/task'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task' import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { MultiSelectChips } from './MultiSelectChips' import { MultiSelectChips } from './MultiSelectChips'
import { CategoryIcon } from './CategoryIcon' import { CategoryIcon } from './CategoryIcon'
import { MultiSelectDropdown } from './MultiSelectDropdown'
export interface TaskFilters { import type { TaskFilters } from './taskFilters'
searchQuery: string
statuses: TaskStatus[]
categoryIds: number[]
projectId: number | 'ALL'
}
export const DEFAULT_TASK_FILTERS: TaskFilters = {
searchQuery: '',
statuses: [],
categoryIds: [],
projectId: 'ALL',
}
interface TaskFilterBarProps { interface TaskFilterBarProps {
filters: TaskFilters filters: TaskFilters
@ -57,25 +44,6 @@ export function TaskFilterBar({
/> />
</div> </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>
)}
{onRefresh && ( {onRefresh && (
<button <button
onClick={onRefresh} onClick={onRefresh}
@ -87,11 +55,27 @@ export function TaskFilterBar({
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="grid gap-3 md:grid-cols-2">
<Filter size={16} className="shrink-0 text-text-muted" /> {projects.length > 0 && (
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Status</span> <MultiSelectDropdown
<MultiSelectChips label="Projetos"
options={TASK_STATUS_OPTIONS.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))} allLabel="Todos os projetos"
options={projects.map((project) => ({
value: project.id,
label: project.name,
}))}
selected={filters.projectIds}
onChange={(projectIds) => update({ projectIds })}
/>
)}
<MultiSelectDropdown
label="Status"
allLabel="Todos os status"
options={TASK_STATUS_OPTIONS.map((status) => ({
value: status,
label: TASK_STATUS_LABELS[status],
}))}
selected={filters.statuses} selected={filters.statuses}
onChange={(statuses) => update({ statuses })} onChange={(statuses) => update({ statuses })}
/> />

View file

@ -0,0 +1,15 @@
import type { TaskStatus } from '../types/task'
export interface TaskFilters {
searchQuery: string
statuses: TaskStatus[]
categoryIds: number[]
projectIds: number[]
}
export const DEFAULT_TASK_FILTERS: TaskFilters = {
searchQuery: '',
statuses: [],
categoryIds: [],
projectIds: [],
}

View file

@ -20,8 +20,8 @@ import type { Category } from '../types/category'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { TaskResponse, TaskStatus } from '../types/task' import type { TaskResponse, TaskStatus } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar' import { TaskFilterBar } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar' import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
import { CategoryBadge } from '../components/CategoryBadge' import { CategoryBadge } from '../components/CategoryBadge'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@ -47,7 +47,7 @@ export function TaskBoardPage() {
try { try {
const response = await taskService.getMyTasks({ const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined, projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined, statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined, categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
}) })

View file

@ -22,8 +22,8 @@ import type { Category } from '../types/category'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { TaskResponse } from '../types/task' import type { TaskResponse } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar, DEFAULT_TASK_FILTERS } from '../components/TaskFilterBar' import { TaskFilterBar } from '../components/TaskFilterBar'
import type { TaskFilters } from '../components/TaskFilterBar' import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
import { CategoryBadge } from '../components/CategoryBadge' import { CategoryBadge } from '../components/CategoryBadge'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@ -71,7 +71,7 @@ export function TaskListPage() {
try { try {
const response = await taskService.getMyTasks({ const response = await taskService.getMyTasks({
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectId: currentFilters.projectId !== 'ALL' ? currentFilters.projectId : undefined, projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined, statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined, categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
}) })

View file

@ -3,7 +3,7 @@ import type { TaskResponse, TaskStatus } from '../types/task'
export interface GetMyTasksFilters { export interface GetMyTasksFilters {
text?: string text?: string
projectId?: number projectIds?: number[]
statuses?: TaskStatus[] statuses?: TaskStatus[]
categoryIds?: number[] categoryIds?: number[]
} }
@ -16,7 +16,7 @@ export const taskService = {
async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> { async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters?.text) params.set('text', filters.text) if (filters?.text) params.set('text', filters.text)
if (filters?.projectId) params.set('projectId', String(filters.projectId)) filters?.projectIds?.forEach((id) => params.append('projectId', String(id)))
filters?.statuses?.forEach((s) => params.append('status', s)) filters?.statuses?.forEach((s) => params.append('status', s))
filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id))) filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
const query = params.toString() const query = params.toString()