Add multi-select task filters for projects and statuses
This commit is contained in:
parent
2d14ae480d
commit
ea7ffaaffd
12 changed files with 190 additions and 59 deletions
|
|
@ -53,7 +53,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
}
|
||||
|
||||
@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) {
|
||||
StringBuilder query = new StringBuilder("user.email = ?1");
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
|
@ -67,9 +67,9 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
params.add(pattern);
|
||||
idx += 2;
|
||||
}
|
||||
if (Objects.nonNull(projectId)) {
|
||||
query.append(" and project.id = ?").append(idx);
|
||||
params.add(projectId);
|
||||
if (Objects.nonNull(projectIds) && !projectIds.isEmpty()) {
|
||||
query.append(" and project.id in ?").append(idx);
|
||||
params.add(projectIds);
|
||||
idx++;
|
||||
}
|
||||
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();
|
||||
LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectId=%s, statuses=%s, categoryIds=%s)",
|
||||
tasks.size(), email, text, projectId, statuses, categoryIds);
|
||||
LOGGER.infof("Loaded %d tasks for user %s with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s)",
|
||||
tasks.size(), email, text, projectIds, statuses, categoryIds);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public class ListMyTasksRequest {
|
|||
public String text;
|
||||
|
||||
@QueryParam("projectId")
|
||||
public Long projectId;
|
||||
public List<Long> projectIds;
|
||||
|
||||
@QueryParam("status")
|
||||
public List<TaskStatus> statuses;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ 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.statuses, request.categoryIds);
|
||||
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
|
||||
}
|
||||
|
||||
public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) {
|
||||
|
|
|
|||
|
|
@ -13,7 +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,
|
||||
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||
List<TaskStatus> statuses, List<Long> categoryIds);
|
||||
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ import java.util.List;
|
|||
|
||||
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) {}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class ListMyTasksUseCase {
|
|||
|
||||
public List<CreateTaskOutput> execute(ListMyTasksInput input) {
|
||||
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();
|
||||
LOGGER.infof("Loaded %d tasks for user %s", tasks.size(), input.email());
|
||||
return tasks;
|
||||
|
|
|
|||
132
frontend/src/components/MultiSelectDropdown.tsx
Normal file
132
frontend/src/components/MultiSelectDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +1,11 @@
|
|||
import { Filter, RefreshCw, Search } from 'lucide-react'
|
||||
import type { TaskStatus } from '../types/task'
|
||||
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from '../types/task'
|
||||
import type { Project } from '../services/projectService'
|
||||
import type { Category } from '../types/category'
|
||||
import { MultiSelectChips } from './MultiSelectChips'
|
||||
import { CategoryIcon } from './CategoryIcon'
|
||||
|
||||
export interface TaskFilters {
|
||||
searchQuery: string
|
||||
statuses: TaskStatus[]
|
||||
categoryIds: number[]
|
||||
projectId: number | 'ALL'
|
||||
}
|
||||
|
||||
export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
||||
searchQuery: '',
|
||||
statuses: [],
|
||||
categoryIds: [],
|
||||
projectId: 'ALL',
|
||||
}
|
||||
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
||||
import type { TaskFilters } from './taskFilters'
|
||||
|
||||
interface TaskFilterBarProps {
|
||||
filters: TaskFilters
|
||||
|
|
@ -57,25 +44,6 @@ export function TaskFilterBar({
|
|||
/>
|
||||
</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 && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
|
|
@ -87,11 +55,27 @@ export function TaskFilterBar({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="shrink-0 text-text-muted" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted whitespace-nowrap">Status</span>
|
||||
<MultiSelectChips
|
||||
options={TASK_STATUS_OPTIONS.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{projects.length > 0 && (
|
||||
<MultiSelectDropdown
|
||||
label="Projetos"
|
||||
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}
|
||||
onChange={(statuses) => update({ statuses })}
|
||||
/>
|
||||
|
|
|
|||
15
frontend/src/components/taskFilters.ts
Normal file
15
frontend/src/components/taskFilters.ts
Normal 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: [],
|
||||
}
|
||||
|
|
@ -20,8 +20,8 @@ import type { Category } from '../types/category'
|
|||
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 { TaskFilterBar } from '../components/TaskFilterBar'
|
||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ export function TaskBoardPage() {
|
|||
try {
|
||||
const response = await taskService.getMyTasks({
|
||||
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,
|
||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import type { Category } from '../types/category'
|
|||
import { cn } from '../lib/utils'
|
||||
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 { TaskFilterBar } from '../components/TaskFilterBar'
|
||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ export function TaskListPage() {
|
|||
try {
|
||||
const response = await taskService.getMyTasks({
|
||||
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,
|
||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { TaskResponse, TaskStatus } from '../types/task'
|
|||
|
||||
export interface GetMyTasksFilters {
|
||||
text?: string
|
||||
projectId?: number
|
||||
projectIds?: number[]
|
||||
statuses?: TaskStatus[]
|
||||
categoryIds?: number[]
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export const taskService = {
|
|||
async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
|
||||
const params = new URLSearchParams()
|
||||
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?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
|
||||
const query = params.toString()
|
||||
|
|
|
|||
Loading…
Reference in a new issue