Add assignee filtering to task listings

This commit is contained in:
Gabriel Santana de Menezes 2026-05-17 20:55:29 -04:00
parent ea7ffaaffd
commit 7f05ebdf61
21 changed files with 413 additions and 75 deletions

View file

@ -35,8 +35,8 @@ public class TaskUseCaseConfig {
} }
@Produces @Produces
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository) { public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
return new ListAllTasksUseCase(taskRepository); return new ListAllTasksUseCase(taskRepository, userRepository);
} }
@Produces @Produces

View file

@ -52,6 +52,51 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return tasks; return tasks;
} }
@Override
public List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
StringBuilder query = new StringBuilder(
"select distinct t from TaskEntity t join t.project p left join p.users pu left join t.user tu " +
"where p.organization.id = ?1 and (pu.email = ?2 or tu.email = ?2)"
);
List<Object> params = new ArrayList<>();
params.add(organizationId);
params.add(email);
int idx = 3;
if (Objects.nonNull(text) && !text.isBlank()) {
query.append(" and (lower(t.title) like ?").append(idx).append(" or lower(t.description) like ?").append(idx + 1).append(")");
String pattern = "%" + text.toLowerCase() + "%";
params.add(pattern);
params.add(pattern);
idx += 2;
}
if (Objects.nonNull(projectIds) && !projectIds.isEmpty()) {
query.append(" and p.id in ?").append(idx);
params.add(projectIds);
idx++;
}
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
query.append(" and t.status in ?").append(idx);
params.add(statuses);
idx++;
}
if (Objects.nonNull(categoryIds) && !categoryIds.isEmpty()) {
query.append(" and t.category.id in ?").append(idx);
params.add(categoryIds);
idx++;
}
if (Objects.nonNull(userIds) && !userIds.isEmpty()) {
query.append(" and tu.id in ?").append(idx);
params.add(userIds);
}
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList();
LOGGER.infof("Loaded %d accessible tasks for user %s in organization %d with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s, userIds=%s)",
tasks.size(), email, organizationId, text, projectIds, statuses, categoryIds, userIds);
return tasks;
}
@Override @Override
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds, public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) { List<TaskStatus> statuses, List<Long> categoryIds) {

View file

@ -12,11 +12,13 @@ public record CreateTaskResponse(
TaskStatus status, TaskStatus status,
ProjectResponse project, ProjectResponse project,
CategoryResponse category, CategoryResponse category,
UserResponse user,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history) {
public record ProjectResponse(Long id, String name) {} public record ProjectResponse(Long id, String name) {}
public record CategoryResponse(Long id, String name, String color, String icon) {} public record CategoryResponse(Long id, String name, String color, String icon) {}
public record UserResponse(Long id, String name, String email, String photo) {}
} }
record TaskHistoryItemResponse( record TaskHistoryItemResponse(

View file

@ -0,0 +1,27 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.List;
import jakarta.ws.rs.QueryParam;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public class ListTasksRequest {
@QueryParam("organizationId")
public Long organizationId;
@QueryParam("text")
public String text;
@QueryParam("projectId")
public List<Long> projectIds;
@QueryParam("status")
public List<TaskStatus> statuses;
@QueryParam("categoryId")
public List<Long> categoryIds;
@QueryParam("userId")
public List<Long> userIds;
}

View file

@ -117,8 +117,9 @@ public class TaskController {
@Operation(summary = "Stop a task", description = "Stops a task in the system") @Operation(summary = "Stop a task", description = "Stops a task in the system")
@APIResponse(responseCode = "200", description = "Task stopped successfully") @APIResponse(responseCode = "200", description = "Task stopped successfully")
public CreateTaskResponse stopTask(@PathParam("id") Long id) { public CreateTaskResponse stopTask(@PathParam("id") Long id) {
LOGGER.infof("Stop requested for task %d", id); String email = securityIdentity.getPrincipal().getName();
CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id)); LOGGER.infof("User %s requested stop for task %d", email, id);
CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id, email));
LOGGER.infof("Task %d stopped successfully", id); LOGGER.infof("Task %d stopped successfully", id);
return TaskControllerMapper.toCreateTaskResponse(output); return TaskControllerMapper.toCreateTaskResponse(output);
} }
@ -137,9 +138,11 @@ public class TaskController {
@GET @GET
@Operation(summary = "List all tasks", description = "Lists all tasks in the system") @Operation(summary = "List all tasks", description = "Lists all tasks in the system")
@APIResponse(responseCode = "200", description = "Tasks listed successfully") @APIResponse(responseCode = "200", description = "Tasks listed successfully")
public List<CreateTaskResponse> listTasks() { public List<CreateTaskResponse> listTasks(@BeanParam ListTasksRequest request) {
List<CreateTaskResponse> tasks = listAllTasksUseCase.execute().stream().map(TaskControllerMapper::toCreateTaskResponse).toList(); String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Listed %d tasks", tasks.size()); List<CreateTaskResponse> tasks = listAllTasksUseCase.execute(TaskControllerMapper.toListTasksInput(request, email))
.stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
LOGGER.infof("Listed %d accessible tasks for user %s", tasks.size(), email);
return tasks; return tasks;
} }

View file

@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import net.beetechgroup.beetask.usecase.task.listall.ListTasksInput;
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput; import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput;
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput; import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput; import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
@ -11,6 +12,18 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper { public class TaskControllerMapper {
public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) {
return new ListTasksInput(
email,
request.organizationId,
request.text,
request.projectIds,
request.statuses,
request.categoryIds,
request.userIds
);
}
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) { public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds); return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
} }
@ -45,6 +58,13 @@ public class TaskControllerMapper {
output.category().color(), output.category().color(),
output.category().icon()) output.category().icon())
: null, : null,
Objects.nonNull(output.user())
? new CreateTaskResponse.UserResponse(
output.user().id(),
output.user().name(),
output.user().email(),
output.user().photo())
: null,
output.finishedAt(), output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList() taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
@ -55,8 +75,8 @@ public class TaskControllerMapper {
return new StartTaskInput(id, email); return new StartTaskInput(id, email);
} }
public static StopTaskInput toStopTaskInput(Long id) { public static StopTaskInput toStopTaskInput(Long id, String email) {
return new StopTaskInput(id); return new StopTaskInput(id, email);
} }
public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) { public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) {

View file

@ -10,6 +10,8 @@ public interface TaskRepository {
Task saveTask(Task task); Task saveTask(Task task);
Task findTaskById(Long id); Task findTaskById(Long id);
List<Task> findAllTasks(); List<Task> findAllTasks();
List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
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);

View file

@ -21,10 +21,24 @@ public class CreateTaskMapper {
task.getCategory().getColor(), task.getCategory().getColor(),
task.getCategory().getIcon()) task.getCategory().getIcon())
: null, : null,
Objects.nonNull(task.getUser())
? new CreateTaskOutput.UserOutput(
task.getUser().getId(),
task.getUser().getName(),
task.getUser().getEmail(),
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
: null,
task.getFinishedAt(), task.getFinishedAt(),
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList()); task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
} }
private static String getPhotoUrl(String name, String photo) {
if (Objects.nonNull(photo) && !photo.isBlank()) {
return photo;
}
return "https://ui-avatars.com/api/?name=" + name.replace(" ", "+") + "&background=random";
}
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) { private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
return new TaskHistoryItemOutput( return new TaskHistoryItemOutput(
taskHistoryItem.getId(), taskHistoryItem.getId(),

View file

@ -11,9 +11,11 @@ public record CreateTaskOutput(
TaskStatus status, TaskStatus status,
ProjectOutput project, ProjectOutput project,
CategoryOutput category, CategoryOutput category,
UserOutput user,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemOutput> history) { List<TaskHistoryItemOutput> history) {
public record ProjectOutput(Long id, String name) {} public record ProjectOutput(Long id, String name) {}
public record CategoryOutput(Long id, String name, String color, String icon) {} public record CategoryOutput(Long id, String name, String color, String icon) {}
public record UserOutput(Long id, String name, String email, String photo) {}
} }

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.usecase.task.listall; package net.beetechgroup.beetask.usecase.task.listall;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@ -11,14 +12,40 @@ public class ListAllTasksUseCase {
private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class); private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserRepository userRepository;
public ListAllTasksUseCase(TaskRepository taskRepository) { public ListAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.userRepository = userRepository;
} }
public List<CreateTaskOutput> execute() { public List<CreateTaskOutput> execute(ListTasksInput input) {
List<CreateTaskOutput> tasks = taskRepository.findAllTasks().stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); if (input.organizationId() == null) {
LOGGER.infof("Loaded %d tasks from repository", tasks.size()); throw new IllegalArgumentException("organizationId is required");
}
var user = userRepository.findByEmail(input.userEmail())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user was not found"));
boolean belongsToOrganization = userRepository.findUserOrganizations(user.getId()).stream()
.anyMatch(membership -> membership.getOrganization().getId().equals(input.organizationId()));
if (!belongsToOrganization) {
throw new SecurityException("Você não tem permissão para listar tarefas desta organização.");
}
List<CreateTaskOutput> tasks = taskRepository.findAccessibleTasksFiltered(
input.userEmail(),
input.organizationId(),
input.text(),
input.projectIds(),
input.statuses(),
input.categoryIds(),
input.userIds())
.stream()
.map(CreateTaskMapper::toCreateTaskOutput)
.toList();
LOGGER.infof("Loaded %d accessible tasks for user %s in organization %d", tasks.size(), input.userEmail(), input.organizationId());
return tasks; return tasks;
} }
} }

View file

@ -0,0 +1,14 @@
package net.beetechgroup.beetask.usecase.task.listall;
import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record ListTasksInput(
String userEmail,
Long organizationId,
String text,
List<Long> projectIds,
List<TaskStatus> statuses,
List<Long> categoryIds,
List<Long> userIds) {}

View file

@ -1,4 +1,4 @@
package net.beetechgroup.beetask.usecase.task.stop; package net.beetechgroup.beetask.usecase.task.stop;
public record StopTaskInput(Long id) { public record StopTaskInput(Long id, String userEmail) {
} }

View file

@ -16,8 +16,15 @@ public class StopTaskUseCase {
} }
public CreateTaskOutput execute(StopTaskInput input) { public CreateTaskOutput execute(StopTaskInput input) {
LOGGER.infof("Stopping task %d", input.id()); LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
Task task = taskRepository.findTaskById(input.id()); Task task = taskRepository.findTaskById(input.id());
if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) {
LOGGER.warnf("User %s attempted to stop task %d owned by %s",
input.userEmail(), input.id(), task.getUser().getEmail());
throw new SecurityException("Você não tem permissão para parar esta tarefa.");
}
task.stop(); task.stop();
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
LOGGER.infof("Task %d stopped successfully", input.id()); LOGGER.infof("Task %d stopped successfully", input.id());

View file

@ -6,12 +6,15 @@ import { MultiSelectChips } from './MultiSelectChips'
import { CategoryIcon } from './CategoryIcon' import { CategoryIcon } from './CategoryIcon'
import { MultiSelectDropdown } from './MultiSelectDropdown' import { MultiSelectDropdown } from './MultiSelectDropdown'
import type { TaskFilters } from './taskFilters' import type { TaskFilters } from './taskFilters'
import type { TaskAssignee } from '../types/task'
import { UserAvatar } from './UserAvatar'
interface TaskFilterBarProps { interface TaskFilterBarProps {
filters: TaskFilters filters: TaskFilters
onFiltersChange: (filters: TaskFilters) => void onFiltersChange: (filters: TaskFilters) => void
projects: Project[] projects: Project[]
categories: Category[] categories: Category[]
assignees: TaskAssignee[]
isLoading?: boolean isLoading?: boolean
onRefresh?: () => void onRefresh?: () => void
} }
@ -21,6 +24,7 @@ export function TaskFilterBar({
onFiltersChange, onFiltersChange,
projects, projects,
categories, categories,
assignees,
isLoading, isLoading,
onRefresh, onRefresh,
}: TaskFilterBarProps) { }: TaskFilterBarProps) {
@ -81,6 +85,22 @@ export function TaskFilterBar({
/> />
</div> </div>
{assignees.length > 0 && (
<div className="grid gap-3">
<MultiSelectDropdown
label="Funcionario"
allLabel="Todos os funcionarios"
options={assignees.map((assignee) => ({
value: assignee.id,
label: assignee.name,
icon: <UserAvatar name={assignee.name} photo={assignee.photo} size="sm" />,
}))}
selected={filters.userIds}
onChange={(userIds) => update({ userIds })}
/>
</div>
)}
{categories.length > 0 && ( {categories.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter size={16} className="shrink-0 text-text-muted" /> <Filter size={16} className="shrink-0 text-text-muted" />

View file

@ -9,6 +9,7 @@ import type { TaskResponse } from '../types/task'
interface TaskTimerProps { interface TaskTimerProps {
task: TaskResponse task: TaskResponse
onUpdate: () => void onUpdate: () => void
canControl?: boolean
} }
function formatDuration(seconds: number) { function formatDuration(seconds: number) {
@ -23,7 +24,7 @@ function formatDuration(seconds: number) {
].join(':') ].join(':')
} }
export function TaskTimer({ task, onUpdate }: TaskTimerProps) { export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) {
const [isActionLoading, setIsActionLoading] = useState(false) const [isActionLoading, setIsActionLoading] = useState(false)
const history = task.history ?? [] const history = task.history ?? []
@ -127,8 +128,9 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
</span> </span>
</div> </div>
<div className="ml-auto pr-1"> {canControl && (
{runningItem ? ( <div className="ml-auto pr-1">
{runningItem ? (
<button <button
onClick={handleStop} onClick={handleStop}
disabled={isActionLoading} disabled={isActionLoading}
@ -137,7 +139,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
> >
<Square size={16} fill="currentColor" /> <Square size={16} fill="currentColor" />
</button> </button>
) : ( ) : (
<button <button
onClick={handleStart} onClick={handleStart}
disabled={isActionLoading} disabled={isActionLoading}
@ -146,8 +148,9 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
> >
<Play size={16} fill="currentColor" className="ml-0.5" /> <Play size={16} fill="currentColor" className="ml-0.5" />
</button> </button>
)} )}
</div> </div>
)}
</div> </div>
) )
} }

View file

@ -0,0 +1,47 @@
import { cn } from '../lib/utils'
interface UserAvatarProps {
name: string
photo?: string | null
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizeClasses = {
sm: 'h-8 w-8 rounded-xl text-xs',
md: 'h-10 w-10 rounded-2xl text-sm',
lg: 'h-14 w-14 rounded-2xl text-xl',
}
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
const initials = name
.split(' ')
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
if (photo) {
return (
<img
src={photo}
alt={name}
title={name}
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
/>
)
}
return (
<div
title={name}
className={cn(
'flex shrink-0 items-center justify-center bg-brand/10 font-black text-brand',
sizeClasses[size],
className,
)}
>
{initials}
</div>
)
}

View file

@ -5,6 +5,7 @@ export interface TaskFilters {
statuses: TaskStatus[] statuses: TaskStatus[]
categoryIds: number[] categoryIds: number[]
projectIds: number[] projectIds: number[]
userIds: number[]
} }
export const DEFAULT_TASK_FILTERS: TaskFilters = { export const DEFAULT_TASK_FILTERS: TaskFilters = {
@ -12,4 +13,5 @@ export const DEFAULT_TASK_FILTERS: TaskFilters = {
statuses: [], statuses: [],
categoryIds: [], categoryIds: [],
projectIds: [], projectIds: [],
userIds: [],
} }

View file

@ -5,6 +5,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
List, List,
type LucideIcon,
MoreVertical, MoreVertical,
Plus, Plus,
XCircle, XCircle,
@ -13,45 +14,78 @@ import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import { categoryService } from '../services/categoryService' import { categoryService } from '../services/categoryService'
import type { Category } from '../types/category' 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 { TaskAssignee, TaskResponse, TaskStatus } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar } from '../components/TaskFilterBar' import { TaskFilterBar } from '../components/TaskFilterBar'
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters' 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'
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [ const COLUMNS: { id: TaskStatus; label: string; icon: LucideIcon; color: string }[] = [
{ id: 'NOT_STARTED', label: 'Pendente', icon: AlertCircle, color: 'text-text-muted' }, { id: 'NOT_STARTED', label: 'Pendente', icon: AlertCircle, color: 'text-text-muted' },
{ id: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' }, { id: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' },
{ id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' }, { id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' },
{ id: 'CANCELED', label: 'Cancelada', icon: XCircle, color: 'text-danger' }, { id: 'CANCELED', label: 'Cancelada', icon: XCircle, color: 'text-danger' },
] ]
function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
const byId = new Map(existing.map((project) => [project.id, project]))
tasks.forEach((task) => {
if (task.project) {
byId.set(task.project.id, { id: task.project.id, name: task.project.name })
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
function extractAssignees(tasks: TaskResponse[]) {
const byId = new Map<number, TaskAssignee>()
tasks.forEach((task) => {
if (task.user) {
byId.set(task.user.id, task.user)
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
export function TaskBoardPage() { export function TaskBoardPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([]) const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS) const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate() const navigate = useNavigate()
const { activeOrg } = useAuth() const { activeOrg, user } = useAuth()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadTasks = async (currentFilters: TaskFilters, silent = false) => { const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
if (!activeOrg) return
if (!silent) setIsLoading(true) if (!silent) setIsLoading(true)
try { try {
const response = await taskService.getMyTasks({ const response = await taskService.getTasks({
organizationId: activeOrg.id,
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : 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,
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
}) })
setTasks(response) setTasks(response)
setProjects((current) => mergeProjects(current, response))
setAssignees((current) => {
const byId = new Map(current.map((assignee) => [assignee.id, assignee]))
extractAssignees(response).forEach((assignee) => byId.set(assignee.id, assignee))
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
})
} catch (error) { } catch (error) {
console.error('Erro ao carregar tarefas', error) console.error('Erro ao carregar tarefas', error)
} finally { } finally {
@ -60,12 +94,14 @@ export function TaskBoardPage() {
} }
useEffect(() => { useEffect(() => {
void loadTasks(filters) if (!activeOrg) return
if (activeOrg) { setFilters(DEFAULT_TASK_FILTERS)
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) setProjects([])
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) setAssignees([])
} setCategories([])
}, []) void loadTasks(DEFAULT_TASK_FILTERS)
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
}, [activeOrg])
const handleFiltersChange = (newFilters: TaskFilters) => { const handleFiltersChange = (newFilters: TaskFilters) => {
setFilters(newFilters) setFilters(newFilters)
@ -145,6 +181,7 @@ export function TaskBoardPage() {
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
projects={projects} projects={projects}
categories={categories} categories={categories}
assignees={assignees}
isLoading={isLoading} isLoading={isLoading}
onRefresh={() => void loadTasks(filters)} onRefresh={() => void loadTasks(filters)}
/> />
@ -180,13 +217,16 @@ export function TaskBoardPage() {
: 'bg-surface/50', : 'bg-surface/50',
)} )}
> >
{tasksByStatus(column.id).map((task, index) => ( {tasksByStatus(column.id).map((task, index) => {
<Draggable const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
key={task.id}
draggableId={task.id.toString()} return (
index={index} <Draggable
> key={task.id}
{(provided, snapshot) => ( draggableId={task.id.toString()}
index={index}
>
{(provided, snapshot) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
@ -211,7 +251,11 @@ export function TaskBoardPage() {
)} )}
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} /> <TaskTimer
task={task}
canControl={canControlTimer}
onUpdate={() => loadTasks(filters, true)}
/>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -227,9 +271,10 @@ export function TaskBoardPage() {
</div> </div>
</div> </div>
</div> </div>
)} )}
</Draggable> </Draggable>
))} )
})}
{provided.placeholder} {provided.placeholder}
</div> </div>
)} )}

View file

@ -15,16 +15,16 @@ import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService'
import type { Project } from '../services/projectService' import type { Project } from '../services/projectService'
import { categoryService } from '../services/categoryService' import { categoryService } from '../services/categoryService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { TaskResponse } from '../types/task' import type { TaskAssignee, TaskResponse } from '../types/task'
import { TaskTimer } from '../components/TaskTimer' import { TaskTimer } from '../components/TaskTimer'
import { TaskFilterBar } from '../components/TaskFilterBar' import { TaskFilterBar } from '../components/TaskFilterBar'
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters' import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
import { CategoryBadge } from '../components/CategoryBadge' import { CategoryBadge } from '../components/CategoryBadge'
import { UserAvatar } from '../components/UserAvatar'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
function getStatusConfig(status: TaskResponse['status']) { function getStatusConfig(status: TaskResponse['status']) {
@ -56,26 +56,60 @@ function getStatusConfig(status: TaskResponse['status']) {
} }
} }
function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
const byId = new Map(existing.map((project) => [project.id, project]))
tasks.forEach((task) => {
if (task.project) {
byId.set(task.project.id, { id: task.project.id, name: task.project.name })
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
function extractAssignees(tasks: TaskResponse[]) {
const byId = new Map<number, TaskAssignee>()
tasks.forEach((task) => {
if (task.user) {
byId.set(task.user.id, task.user)
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
export function TaskListPage() { export function TaskListPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([]) const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS) const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const navigate = useNavigate() const navigate = useNavigate()
const { activeOrg } = useAuth() const { activeOrg, user } = useAuth()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadTasks = async (currentFilters: TaskFilters, silent = false) => { const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
if (!activeOrg) return
if (!silent) setIsLoading(true) if (!silent) setIsLoading(true)
try { try {
const response = await taskService.getMyTasks({ const response = await taskService.getTasks({
organizationId: activeOrg.id,
text: currentFilters.searchQuery || undefined, text: currentFilters.searchQuery || undefined,
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : 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,
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
}) })
setTasks(response) setTasks(response)
setProjects((current) => mergeProjects(current, response))
setAssignees((current) => {
const byId = new Map(current.map((assignee) => [assignee.id, assignee]))
extractAssignees(response).forEach((assignee) => byId.set(assignee.id, assignee))
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
})
} catch (error) { } catch (error) {
console.error('Erro ao carregar tarefas', error) console.error('Erro ao carregar tarefas', error)
} finally { } finally {
@ -84,12 +118,14 @@ export function TaskListPage() {
} }
useEffect(() => { useEffect(() => {
void loadTasks(filters) if (!activeOrg) return
if (activeOrg) { setFilters(DEFAULT_TASK_FILTERS)
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) setProjects([])
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) setAssignees([])
} setCategories([])
}, []) void loadTasks(DEFAULT_TASK_FILTERS)
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
}, [activeOrg])
const handleFiltersChange = (newFilters: TaskFilters) => { const handleFiltersChange = (newFilters: TaskFilters) => {
setFilters(newFilters) setFilters(newFilters)
@ -139,6 +175,7 @@ export function TaskListPage() {
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
projects={projects} projects={projects}
categories={categories} categories={categories}
assignees={assignees}
isLoading={isLoading} isLoading={isLoading}
onRefresh={() => void loadTasks(filters)} onRefresh={() => void loadTasks(filters)}
/> />
@ -148,6 +185,7 @@ export function TaskListPage() {
{tasks.map((task, i) => { {tasks.map((task, i) => {
const config = getStatusConfig(task.status) const config = getStatusConfig(task.status)
const StatusIcon = config.icon const StatusIcon = config.icon
const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
return ( return (
<motion.article <motion.article
@ -184,24 +222,36 @@ export function TaskListPage() {
</p> </p>
<div className="mt-6 flex flex-col gap-4"> <div className="mt-6 flex flex-col gap-4">
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} /> <TaskTimer
task={task}
canControl={canControlTimer}
onUpdate={() => loadTasks(filters, true)}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-border-soft/50 pt-4"> <div className="flex flex-wrap items-center justify-between 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"> <div className="flex flex-wrap items-center gap-3">
<Tag size={14} className="text-brand" /> <div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
{task.project?.name || 'Geral'} <Tag size={14} className="text-brand" />
{task.project?.name || 'Geral'}
</div>
{task.category && (
<>
<div className="h-4 w-px bg-border-soft" />
<CategoryBadge category={task.category} />
</>
)}
<div className="h-4 w-px bg-border-soft" />
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
<Calendar size={14} className="text-accent" />
{task.history?.length ?? 0} registros
</div>
</div> </div>
{task.category && (
<> {task.user && (
<div className="h-4 w-px bg-border-soft" /> <div className="flex items-center gap-2 text-xs font-bold text-text-muted">
<CategoryBadge category={task.category} /> <UserAvatar name={task.user.name} photo={task.user.photo} size="sm" />
</> </div>
)} )}
<div className="h-4 w-px bg-border-soft" />
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
<Calendar size={14} className="text-accent" />
{task.history?.length ?? 0} registros
</div>
</div> </div>
</div> </div>
</motion.article> </motion.article>

View file

@ -1,26 +1,26 @@
import { apiFetch } from '../lib/api' import { apiFetch } from '../lib/api'
import type { TaskResponse, TaskStatus } from '../types/task' import type { TaskResponse, TaskStatus } from '../types/task'
export interface GetMyTasksFilters { export interface GetTasksFilters {
organizationId: number
text?: string text?: string
projectIds?: number[] projectIds?: number[]
statuses?: TaskStatus[] statuses?: TaskStatus[]
categoryIds?: number[] categoryIds?: number[]
userIds?: number[]
} }
export const taskService = { export const taskService = {
async getTasks(): Promise<TaskResponse[]> { async getTasks(filters: GetTasksFilters): Promise<TaskResponse[]> {
return apiFetch<TaskResponse[]>('/tasks')
},
async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('organizationId', String(filters.organizationId))
if (filters?.text) params.set('text', filters.text) if (filters?.text) params.set('text', filters.text)
filters?.projectIds?.forEach((id) => params.append('projectId', String(id))) 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)))
filters?.userIds?.forEach((id) => params.append('userId', String(id)))
const query = params.toString() const query = params.toString()
return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`) return apiFetch<TaskResponse[]>(`/tasks${query ? `?${query}` : ''}`)
}, },
async createTask(task: { async createTask(task: {

View file

@ -40,6 +40,13 @@ export type TaskCategory = {
icon: string icon: string
} }
export type TaskAssignee = {
id: number
name: string
email: string
photo: string
}
export type TaskResponse = { export type TaskResponse = {
id: number id: number
title: string title: string
@ -47,6 +54,7 @@ export type TaskResponse = {
status: TaskStatus status: TaskStatus
project: TaskProject | null project: TaskProject | null
category: TaskCategory | null category: TaskCategory | null
user: TaskAssignee | null
finishedAt?: string finishedAt?: string
history?: TaskHistoryItem[] history?: TaskHistoryItem[]
} }