diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java index df99e08..1eb1422 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java @@ -35,8 +35,8 @@ public class TaskUseCaseConfig { } @Produces - public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository) { - return new ListAllTasksUseCase(taskRepository); + public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) { + return new ListAllTasksUseCase(taskRepository, userRepository); } @Produces diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index a108c03..d9c2daf 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -52,6 +52,51 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findAccessibleTasksFiltered(String email, Long organizationId, String text, List projectIds, + List statuses, List categoryIds, List 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 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 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 public List findTasksByUserFiltered(String email, String text, List projectIds, List statuses, List categoryIds) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java index 99a6227..64573a5 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java @@ -12,11 +12,13 @@ public record CreateTaskResponse( TaskStatus status, ProjectResponse project, CategoryResponse category, + UserResponse user, LocalDateTime finishedAt, List history) { public record ProjectResponse(Long id, String name) {} public record CategoryResponse(Long id, String name, String color, String icon) {} + public record UserResponse(Long id, String name, String email, String photo) {} } record TaskHistoryItemResponse( diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java new file mode 100644 index 0000000..c078e7a --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java @@ -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 projectIds; + + @QueryParam("status") + public List statuses; + + @QueryParam("categoryId") + public List categoryIds; + + @QueryParam("userId") + public List userIds; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java index 3b11b84..9fe7046 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java @@ -117,8 +117,9 @@ public class TaskController { @Operation(summary = "Stop a task", description = "Stops a task in the system") @APIResponse(responseCode = "200", description = "Task stopped successfully") public CreateTaskResponse stopTask(@PathParam("id") Long id) { - LOGGER.infof("Stop requested for task %d", id); - CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id)); + String email = securityIdentity.getPrincipal().getName(); + 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); return TaskControllerMapper.toCreateTaskResponse(output); } @@ -137,9 +138,11 @@ public class TaskController { @GET @Operation(summary = "List all tasks", description = "Lists all tasks in the system") @APIResponse(responseCode = "200", description = "Tasks listed successfully") - public List listTasks() { - List tasks = listAllTasksUseCase.execute().stream().map(TaskControllerMapper::toCreateTaskResponse).toList(); - LOGGER.infof("Listed %d tasks", tasks.size()); + public List listTasks(@BeanParam ListTasksRequest request) { + String email = securityIdentity.getPrincipal().getName(); + List 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; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index 5eb4650..f55c668 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import java.util.Objects; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; +import net.beetechgroup.beetask.usecase.task.listall.ListTasksInput; import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput; import net.beetechgroup.beetask.usecase.task.start.StartTaskInput; import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput; @@ -11,6 +12,18 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput; 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) { return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds); } @@ -45,6 +58,13 @@ public class TaskControllerMapper { output.category().color(), output.category().icon()) : null, + Objects.nonNull(output.user()) + ? new CreateTaskResponse.UserResponse( + output.user().id(), + output.user().name(), + output.user().email(), + output.user().photo()) + : null, output.finishedAt(), output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList() @@ -55,8 +75,8 @@ public class TaskControllerMapper { return new StartTaskInput(id, email); } - public static StopTaskInput toStopTaskInput(Long id) { - return new StopTaskInput(id); + public static StopTaskInput toStopTaskInput(Long id, String email) { + return new StopTaskInput(id, email); } public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index 45f0833..58acb36 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -10,6 +10,8 @@ public interface TaskRepository { Task saveTask(Task task); Task findTaskById(Long id); List findAllTasks(); + List findAccessibleTasksFiltered(String email, Long organizationId, String text, List projectIds, + List statuses, List categoryIds, List userIds); List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java index 75fe2ec..a3f6d5d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java @@ -21,10 +21,24 @@ public class CreateTaskMapper { task.getCategory().getColor(), task.getCategory().getIcon()) : 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.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) { return new TaskHistoryItemOutput( taskHistoryItem.getId(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java index 43b531f..b3ead68 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskOutput.java @@ -11,9 +11,11 @@ public record CreateTaskOutput( TaskStatus status, ProjectOutput project, CategoryOutput category, + UserOutput user, LocalDateTime finishedAt, List history) { public record ProjectOutput(Long id, String name) {} public record CategoryOutput(Long id, String name, String color, String icon) {} + public record UserOutput(Long id, String name, String email, String photo) {} } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListAllTasksUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListAllTasksUseCase.java index 92ba076..848d1f8 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListAllTasksUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListAllTasksUseCase.java @@ -1,6 +1,7 @@ package net.beetechgroup.beetask.usecase.task.listall; 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.CreateTaskOutput; import org.jboss.logging.Logger; @@ -11,14 +12,40 @@ public class ListAllTasksUseCase { private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class); private final TaskRepository taskRepository; + private final UserRepository userRepository; - public ListAllTasksUseCase(TaskRepository taskRepository) { + public ListAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) { this.taskRepository = taskRepository; + this.userRepository = userRepository; } - public List execute() { - List tasks = taskRepository.findAllTasks().stream().map(CreateTaskMapper::toCreateTaskOutput).toList(); - LOGGER.infof("Loaded %d tasks from repository", tasks.size()); + public List execute(ListTasksInput input) { + if (input.organizationId() == null) { + 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 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; } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java new file mode 100644 index 0000000..64e8998 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java @@ -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 projectIds, + List statuses, + List categoryIds, + List userIds) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskInput.java index 95fe40a..71e1a6b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskInput.java @@ -1,4 +1,4 @@ package net.beetechgroup.beetask.usecase.task.stop; -public record StopTaskInput(Long id) { +public record StopTaskInput(Long id, String userEmail) { } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java index d71e385..8f137ea 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/stop/StopTaskUseCase.java @@ -16,8 +16,15 @@ public class StopTaskUseCase { } 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()); + + 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(); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); LOGGER.infof("Task %d stopped successfully", input.id()); diff --git a/frontend/src/components/TaskFilterBar.tsx b/frontend/src/components/TaskFilterBar.tsx index 6b3ef13..df39cb6 100644 --- a/frontend/src/components/TaskFilterBar.tsx +++ b/frontend/src/components/TaskFilterBar.tsx @@ -6,12 +6,15 @@ import { MultiSelectChips } from './MultiSelectChips' import { CategoryIcon } from './CategoryIcon' import { MultiSelectDropdown } from './MultiSelectDropdown' import type { TaskFilters } from './taskFilters' +import type { TaskAssignee } from '../types/task' +import { UserAvatar } from './UserAvatar' interface TaskFilterBarProps { filters: TaskFilters onFiltersChange: (filters: TaskFilters) => void projects: Project[] categories: Category[] + assignees: TaskAssignee[] isLoading?: boolean onRefresh?: () => void } @@ -21,6 +24,7 @@ export function TaskFilterBar({ onFiltersChange, projects, categories, + assignees, isLoading, onRefresh, }: TaskFilterBarProps) { @@ -81,6 +85,22 @@ export function TaskFilterBar({ /> + {assignees.length > 0 && ( +
+ ({ + value: assignee.id, + label: assignee.name, + icon: , + }))} + selected={filters.userIds} + onChange={(userIds) => update({ userIds })} + /> +
+ )} + {categories.length > 0 && (
diff --git a/frontend/src/components/TaskTimer.tsx b/frontend/src/components/TaskTimer.tsx index 3db7873..2d6d135 100644 --- a/frontend/src/components/TaskTimer.tsx +++ b/frontend/src/components/TaskTimer.tsx @@ -9,6 +9,7 @@ import type { TaskResponse } from '../types/task' interface TaskTimerProps { task: TaskResponse onUpdate: () => void + canControl?: boolean } function formatDuration(seconds: number) { @@ -23,7 +24,7 @@ function formatDuration(seconds: number) { ].join(':') } -export function TaskTimer({ task, onUpdate }: TaskTimerProps) { +export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) { const [isActionLoading, setIsActionLoading] = useState(false) const history = task.history ?? [] @@ -127,8 +128,9 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
-
- {runningItem ? ( + {canControl && ( +
+ {runningItem ? ( - ) : ( + ) : ( - )} -
+ )} +
+ )} ) } diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 0000000..97fadf9 --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -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 ( + {name} + ) + } + + return ( +
+ {initials} +
+ ) +} diff --git a/frontend/src/components/taskFilters.ts b/frontend/src/components/taskFilters.ts index 07d531e..b5c59d3 100644 --- a/frontend/src/components/taskFilters.ts +++ b/frontend/src/components/taskFilters.ts @@ -5,6 +5,7 @@ export interface TaskFilters { statuses: TaskStatus[] categoryIds: number[] projectIds: number[] + userIds: number[] } export const DEFAULT_TASK_FILTERS: TaskFilters = { @@ -12,4 +13,5 @@ export const DEFAULT_TASK_FILTERS: TaskFilters = { statuses: [], categoryIds: [], projectIds: [], + userIds: [], } diff --git a/frontend/src/pages/TaskBoardPage.tsx b/frontend/src/pages/TaskBoardPage.tsx index d67b683..5981591 100644 --- a/frontend/src/pages/TaskBoardPage.tsx +++ b/frontend/src/pages/TaskBoardPage.tsx @@ -5,6 +5,7 @@ import { CheckCircle2, Clock, List, + type LucideIcon, MoreVertical, Plus, XCircle, @@ -13,45 +14,78 @@ 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 { categoryService } from '../services/categoryService' import type { Category } from '../types/category' 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 { TaskFilterBar } from '../components/TaskFilterBar' import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters' import { CategoryBadge } from '../components/CategoryBadge' 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: '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' }, ] +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() + + 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() { const [tasks, setTasks] = useState([]) const [isLoading, setIsLoading] = useState(true) const [projects, setProjects] = useState([]) const [categories, setCategories] = useState([]) + const [assignees, setAssignees] = useState([]) const [filters, setFilters] = useState(DEFAULT_TASK_FILTERS) const navigate = useNavigate() - const { activeOrg } = useAuth() + const { activeOrg, user } = useAuth() const debounceRef = useRef | null>(null) const loadTasks = async (currentFilters: TaskFilters, silent = false) => { + if (!activeOrg) return if (!silent) setIsLoading(true) try { - const response = await taskService.getMyTasks({ + const response = await taskService.getTasks({ + organizationId: activeOrg.id, text: currentFilters.searchQuery || 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, + userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined, }) 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) { console.error('Erro ao carregar tarefas', error) } finally { @@ -60,12 +94,14 @@ export function TaskBoardPage() { } useEffect(() => { - void loadTasks(filters) - if (activeOrg) { - projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) - categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) - } - }, []) + if (!activeOrg) return + setFilters(DEFAULT_TASK_FILTERS) + setProjects([]) + setAssignees([]) + setCategories([]) + void loadTasks(DEFAULT_TASK_FILTERS) + categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) + }, [activeOrg]) const handleFiltersChange = (newFilters: TaskFilters) => { setFilters(newFilters) @@ -145,6 +181,7 @@ export function TaskBoardPage() { onFiltersChange={handleFiltersChange} projects={projects} categories={categories} + assignees={assignees} isLoading={isLoading} onRefresh={() => void loadTasks(filters)} /> @@ -180,13 +217,16 @@ export function TaskBoardPage() { : 'bg-surface/50', )} > - {tasksByStatus(column.id).map((task, index) => ( - - {(provided, snapshot) => ( + {tasksByStatus(column.id).map((task, index) => { + const canControlTimer = Boolean(user?.email && task.user?.email === user.email) + + return ( + + {(provided, snapshot) => (
- loadTasks(filters, true)} /> + loadTasks(filters, true)} + />
@@ -227,9 +271,10 @@ export function TaskBoardPage() {
- )} -
- ))} + )} +
+ ) + })} {provided.placeholder} )} diff --git a/frontend/src/pages/TaskListPage.tsx b/frontend/src/pages/TaskListPage.tsx index b827915..6aab0a1 100644 --- a/frontend/src/pages/TaskListPage.tsx +++ b/frontend/src/pages/TaskListPage.tsx @@ -15,16 +15,16 @@ 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 { categoryService } from '../services/categoryService' import type { Category } from '../types/category' import { cn } from '../lib/utils' -import type { TaskResponse } from '../types/task' +import type { TaskAssignee, TaskResponse } from '../types/task' import { TaskTimer } from '../components/TaskTimer' import { TaskFilterBar } from '../components/TaskFilterBar' import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters' import { CategoryBadge } from '../components/CategoryBadge' +import { UserAvatar } from '../components/UserAvatar' import { useAuth } from '../contexts/AuthContext' 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() + + 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() { const [tasks, setTasks] = useState([]) const [isLoading, setIsLoading] = useState(true) const [projects, setProjects] = useState([]) const [categories, setCategories] = useState([]) + const [assignees, setAssignees] = useState([]) const [filters, setFilters] = useState(DEFAULT_TASK_FILTERS) const navigate = useNavigate() - const { activeOrg } = useAuth() + const { activeOrg, user } = useAuth() const debounceRef = useRef | null>(null) const loadTasks = async (currentFilters: TaskFilters, silent = false) => { + if (!activeOrg) return if (!silent) setIsLoading(true) try { - const response = await taskService.getMyTasks({ + const response = await taskService.getTasks({ + organizationId: activeOrg.id, text: currentFilters.searchQuery || 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, + userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined, }) 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) { console.error('Erro ao carregar tarefas', error) } finally { @@ -84,12 +118,14 @@ export function TaskListPage() { } useEffect(() => { - void loadTasks(filters) - if (activeOrg) { - projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}) - categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) - } - }, []) + if (!activeOrg) return + setFilters(DEFAULT_TASK_FILTERS) + setProjects([]) + setAssignees([]) + setCategories([]) + void loadTasks(DEFAULT_TASK_FILTERS) + categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}) + }, [activeOrg]) const handleFiltersChange = (newFilters: TaskFilters) => { setFilters(newFilters) @@ -139,6 +175,7 @@ export function TaskListPage() { onFiltersChange={handleFiltersChange} projects={projects} categories={categories} + assignees={assignees} isLoading={isLoading} onRefresh={() => void loadTasks(filters)} /> @@ -148,6 +185,7 @@ export function TaskListPage() { {tasks.map((task, i) => { const config = getStatusConfig(task.status) const StatusIcon = config.icon + const canControlTimer = Boolean(user?.email && task.user?.email === user.email) return (
- loadTasks(filters, true)} /> + loadTasks(filters, true)} + /> -
-
- - {task.project?.name || 'Geral'} +
+
+
+ + {task.project?.name || 'Geral'} +
+ {task.category && ( + <> +
+ + + )} +
+
+ + {task.history?.length ?? 0} registros +
- {task.category && ( - <> -
- - + + {task.user && ( +
+ +
)} -
-
- - {task.history?.length ?? 0} registros -
diff --git a/frontend/src/services/taskService.ts b/frontend/src/services/taskService.ts index 3f64962..9204b64 100644 --- a/frontend/src/services/taskService.ts +++ b/frontend/src/services/taskService.ts @@ -1,26 +1,26 @@ import { apiFetch } from '../lib/api' import type { TaskResponse, TaskStatus } from '../types/task' -export interface GetMyTasksFilters { +export interface GetTasksFilters { + organizationId: number text?: string projectIds?: number[] statuses?: TaskStatus[] categoryIds?: number[] + userIds?: number[] } export const taskService = { - async getTasks(): Promise { - return apiFetch('/tasks') - }, - - async getMyTasks(filters?: GetMyTasksFilters): Promise { + async getTasks(filters: GetTasksFilters): Promise { const params = new URLSearchParams() + params.set('organizationId', String(filters.organizationId)) if (filters?.text) params.set('text', filters.text) 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))) + filters?.userIds?.forEach((id) => params.append('userId', String(id))) const query = params.toString() - return apiFetch(`/tasks/mine${query ? `?${query}` : ''}`) + return apiFetch(`/tasks${query ? `?${query}` : ''}`) }, async createTask(task: { diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 0eebb7e..1f257ed 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -40,6 +40,13 @@ export type TaskCategory = { icon: string } +export type TaskAssignee = { + id: number + name: string + email: string + photo: string +} + export type TaskResponse = { id: number title: string @@ -47,6 +54,7 @@ export type TaskResponse = { status: TaskStatus project: TaskProject | null category: TaskCategory | null + user: TaskAssignee | null finishedAt?: string history?: TaskHistoryItem[] }