Add optional assignee to task form

This commit is contained in:
Gabriel Santana de Menezes 2026-05-17 21:18:07 -04:00
parent 7c3ba32b3f
commit ae0d536ff7
15 changed files with 328 additions and 23 deletions

View file

@ -9,6 +9,7 @@ import net.beetechgroup.beetask.usecase.organization.requests.ListPendingRequest
import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase; import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase;
import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase; import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
@ -58,4 +59,10 @@ public class OrganizationUseCaseConfig {
UserRepository userRepository) { UserRepository userRepository) {
return new ListUserJoinRequestsUseCase(userOrganizationRepository, userRepository); return new ListUserJoinRequestsUseCase(userOrganizationRepository, userRepository);
} }
@Produces
public ListOrganizationMembersUseCase listOrganizationMembersUseCase(UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository);
}
} }

View file

@ -5,6 +5,7 @@ import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase; import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
@ -20,8 +21,9 @@ public class TaskUseCaseConfig {
@Produces @Produces
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) { CategoryRepository categoryRepository, UserRepository userRepository,
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository); UserOrganizationRepository userOrganizationRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
} }
@Produces @Produces
@ -51,8 +53,9 @@ public class TaskUseCaseConfig {
@Produces @Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) { CategoryRepository categoryRepository, UserRepository userRepository,
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository); UserOrganizationRepository userOrganizationRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
} }
@Produces @Produces

View file

@ -21,6 +21,8 @@ import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinReques
import net.beetechgroup.beetask.usecase.organization.requests.UserJoinRequestOutput; import net.beetechgroup.beetask.usecase.organization.requests.UserJoinRequestOutput;
import net.beetechgroup.beetask.usecase.organization.requests.JoinRequestOutput; import net.beetechgroup.beetask.usecase.organization.requests.JoinRequestOutput;
import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase; import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase;
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
import net.beetechgroup.beetask.usecase.organization.members.OrganizationMemberOutput;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.util.List; import java.util.List;
@ -47,6 +49,9 @@ public class OrganizationController {
@Inject @Inject
HandleJoinRequestUseCase handleJoinRequestUseCase; HandleJoinRequestUseCase handleJoinRequestUseCase;
@Inject
ListOrganizationMembersUseCase listOrganizationMembersUseCase;
@Inject @Inject
SecurityIdentity securityIdentity; SecurityIdentity securityIdentity;
@ -116,6 +121,17 @@ public class OrganizationController {
LOGGER.infof("Join request rejected for user %d in organization %d by %s", userId, organizationId, userEmail); LOGGER.infof("Join request rejected for user %d in organization %d by %s", userId, organizationId, userEmail);
} }
@GET
@Path("/{id}/members")
@Authenticated
@Operation(summary = "List organization members", description = "List all active members of an organization")
public List<OrganizationMemberOutput> listMembers(@PathParam("id") Long organizationId) {
String userEmail = securityIdentity.getPrincipal().getName();
List<OrganizationMemberOutput> members = listOrganizationMembersUseCase.execute(userEmail, organizationId);
LOGGER.infof("User %s listed %d active members for organization %d", userEmail, members.size(), organizationId);
return members;
}
@GET @GET
@Path("/my-requests") @Path("/my-requests")
@Authenticated @Authenticated

View file

@ -8,7 +8,9 @@ public record CreateTaskRequest(
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history) {
} }

View file

@ -33,8 +33,10 @@ public class TaskControllerMapper {
request.title(), request.title(),
request.description(), request.description(),
request.status(), request.status(),
request.organizationId(),
request.projectId(), request.projectId(),
request.categoryId(), request.categoryId(),
request.assigneeUserId(),
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
@ -85,8 +87,10 @@ public class TaskControllerMapper {
request.title(), request.title(),
request.description(), request.description(),
request.status(), request.status(),
request.organizationId(),
request.projectId(), request.projectId(),
request.categoryId(), request.categoryId(),
request.assigneeUserId(),
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))

View file

@ -0,0 +1,47 @@
package net.beetechgroup.beetask.usecase.organization.members;
import java.util.List;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
public class ListOrganizationMembersUseCase {
private static final Logger LOGGER = Logger.getLogger(ListOrganizationMembersUseCase.class);
private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
public ListOrganizationMembersUseCase(UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
}
public List<OrganizationMemberOutput> execute(String userEmail, Long organizationId) {
var requester = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado: " + userEmail));
boolean belongsToOrganization = userRepository.findUserOrganizations(requester.getId()).stream()
.anyMatch(membership -> membership.getOrganization().getId().equals(organizationId));
if (!belongsToOrganization) {
LOGGER.warnf("User %s attempted to list members of organization %d without membership", userEmail, organizationId);
throw new SecurityException("Você não tem permissão para listar membros desta organização.");
}
List<OrganizationMemberOutput> members = userOrganizationRepository
.findByOrganizationIdAndStatus(organizationId, UserOrganizationStatus.ACTIVE)
.stream()
.map(membership -> new OrganizationMemberOutput(
membership.getUser().getId(),
membership.getUser().getName(),
membership.getUser().getEmail(),
membership.getUser().getPhoto()))
.toList();
LOGGER.infof("Loaded %d active members for organization %d requested by %s", members.size(), organizationId, userEmail);
return members;
}
}

View file

@ -0,0 +1,7 @@
package net.beetechgroup.beetask.usecase.organization.members;
public record OrganizationMemberOutput(
Long userId,
String userName,
String userEmail,
String userPhoto) {}

View file

@ -8,8 +8,10 @@ public record CreateTaskInput(
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history) {
} }

View file

@ -4,8 +4,11 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
@ -18,13 +21,16 @@ public class CreateTaskUseCase {
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) { CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
} }
public CreateTaskOutput execute(CreateTaskInput input) { public CreateTaskOutput execute(CreateTaskInput input) {
@ -52,11 +58,8 @@ public class CreateTaskUseCase {
task.setCategory(category); task.setCategory(category);
} }
task.setUser(userRepository.findByEmail(input.userEmail()) validateRequesterMembership(input.organizationId(), input.userEmail());
.orElseThrow(() -> { task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
LOGGER.warnf("Task creation failed because user %s was not found", input.userEmail());
return new IllegalArgumentException("Usuário não encontrado: " + input.userEmail());
}));
if (Objects.nonNull(input.history())) { if (Objects.nonNull(input.history())) {
List<TaskHistoryItem> historyItems = input.history().stream().map(h -> { List<TaskHistoryItem> historyItems = input.history().stream().map(h -> {
@ -72,4 +75,46 @@ public class CreateTaskUseCase {
LOGGER.infof("Task %d created for user %s", output.id(), input.userEmail()); LOGGER.infof("Task %d created for user %s", output.id(), input.userEmail());
return output; return output;
} }
private void validateRequesterMembership(Long organizationId, String userEmail) {
if (Objects.isNull(organizationId)) {
throw new IllegalArgumentException("Organização é obrigatória para criar a tarefa.");
}
User requester = userRepository.findByEmail(userEmail)
.orElseThrow(() -> {
LOGGER.warnf("Task creation failed because user %s was not found", userEmail);
return new IllegalArgumentException("Usuário não encontrado: " + userEmail);
});
boolean belongsToOrganization = userRepository.findUserOrganizations(requester.getId()).stream()
.anyMatch(membership -> membership.getOrganization().getId().equals(organizationId));
if (!belongsToOrganization) {
LOGGER.warnf("Task creation denied because user %s is not an active member of organization %d", userEmail, organizationId);
throw new SecurityException("Você não tem permissão para criar tarefas nesta organização.");
}
}
private User resolveAssignee(Long organizationId, Long assigneeUserId) {
if (Objects.isNull(assigneeUserId)) {
return null;
}
boolean belongsToOrganization = userOrganizationRepository
.findByOrganizationIdAndStatus(organizationId, UserOrganizationStatus.ACTIVE)
.stream()
.anyMatch(membership -> membership.getUser().getId().equals(assigneeUserId));
if (!belongsToOrganization) {
LOGGER.warnf("Task creation failed because assignee %d is not an active member of organization %d", assigneeUserId, organizationId);
throw new IllegalArgumentException("Responsável não pertence à organização selecionada.");
}
return userRepository.findUserById(assigneeUserId)
.orElseThrow(() -> {
LOGGER.warnf("Task creation failed because assignee %d was not found", assigneeUserId);
return new IllegalArgumentException("Usuário responsável não encontrado: " + assigneeUserId);
});
}
} }

View file

@ -8,8 +8,10 @@ public record UpdateTaskInput(
String title, String title,
String description, String description,
TaskStatus status, TaskStatus status,
Long organizationId,
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history) {
} }

View file

@ -4,11 +4,14 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; 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;
@ -20,26 +23,23 @@ public class UpdateTaskUseCase {
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository) { CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
} }
public CreateTaskOutput execute(UpdateTaskInput input) { public CreateTaskOutput execute(UpdateTaskInput input) {
LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail()); LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail());
Task task = taskRepository.findTaskById(input.id()); Task task = taskRepository.findTaskById(input.id());
if (Objects.isNull(task.getUser())) { validateRequesterMembership(input.organizationId(), input.userEmail());
task.setUser(userRepository.findByEmail(input.userEmail())
.orElseThrow(() -> {
LOGGER.warnf("Task update failed because user %s was not found", input.userEmail());
return new IllegalArgumentException("Usuário não encontrado: " + input.userEmail());
}));
}
task.setTitle(input.title()); task.setTitle(input.title());
task.setDescription(input.description()); task.setDescription(input.description());
@ -67,6 +67,8 @@ public class UpdateTaskUseCase {
task.setCategory(null); task.setCategory(null);
} }
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
if (Objects.nonNull(input.history())) { if (Objects.nonNull(input.history())) {
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> { List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
if (Objects.nonNull(hInput.id())) { if (Objects.nonNull(hInput.id())) {
@ -101,4 +103,46 @@ public class UpdateTaskUseCase {
LOGGER.infof("Task %d updated successfully", input.id()); LOGGER.infof("Task %d updated successfully", input.id());
return output; return output;
} }
private void validateRequesterMembership(Long organizationId, String userEmail) {
if (Objects.isNull(organizationId)) {
throw new IllegalArgumentException("Organização é obrigatória para atualizar a tarefa.");
}
User requester = userRepository.findByEmail(userEmail)
.orElseThrow(() -> {
LOGGER.warnf("Task update failed because user %s was not found", userEmail);
return new IllegalArgumentException("Usuário não encontrado: " + userEmail);
});
boolean belongsToOrganization = userRepository.findUserOrganizations(requester.getId()).stream()
.anyMatch(membership -> membership.getOrganization().getId().equals(organizationId));
if (!belongsToOrganization) {
LOGGER.warnf("Task update denied because user %s is not an active member of organization %d", userEmail, organizationId);
throw new SecurityException("Você não tem permissão para atualizar tarefas nesta organização.");
}
}
private User resolveAssignee(Long organizationId, Long assigneeUserId) {
if (Objects.isNull(assigneeUserId)) {
return null;
}
boolean belongsToOrganization = userOrganizationRepository
.findByOrganizationIdAndStatus(organizationId, UserOrganizationStatus.ACTIVE)
.stream()
.anyMatch(membership -> membership.getUser().getId().equals(assigneeUserId));
if (!belongsToOrganization) {
LOGGER.warnf("Task update failed because assignee %d is not an active member of organization %d", assigneeUserId, organizationId);
throw new IllegalArgumentException("Responsável não pertence à organização selecionada.");
}
return userRepository.findUserById(assigneeUserId)
.orElseThrow(() -> {
LOGGER.warnf("Task update failed because assignee %d was not found", assigneeUserId);
return new IllegalArgumentException("Usuário responsável não encontrado: " + assigneeUserId);
});
}
} }

View file

@ -7,6 +7,7 @@ import {
Layout, Layout,
PlusCircle, PlusCircle,
Send, Send,
Users,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
@ -22,12 +23,18 @@ import { useAuth } from "../contexts/AuthContext";
import { useEffect } from "react"; import { useEffect } from "react";
import { projectService, type Project } from "../services/projectService"; import { projectService, type Project } from "../services/projectService";
import { categoryService } from "../services/categoryService"; import { categoryService } from "../services/categoryService";
import {
organizationService,
type OrganizationMember,
} from "../services/organizationService";
import type { Category } from "../types/category"; import type { Category } from "../types/category";
import { CategoryIcon } from "../components/CategoryIcon"; import { CategoryIcon } from "../components/CategoryIcon";
import { UserAvatar } from "../components/UserAvatar";
type FormState = Omit<CreateTaskPayload, 'projectId' | 'categoryId'> & { type FormState = Omit<CreateTaskPayload, 'organizationId' | 'projectId' | 'categoryId' | 'assigneeUserId'> & {
projectId: string; projectId: string;
categoryId: string; categoryId: string;
assigneeUserId: string;
history: TaskHistoryItem[]; history: TaskHistoryItem[];
}; };
@ -37,6 +44,7 @@ const initialFormState: FormState = {
status: "NOT_STARTED", status: "NOT_STARTED",
projectId: "", projectId: "",
categoryId: "", categoryId: "",
assigneeUserId: "",
history: [], history: [],
}; };
@ -47,7 +55,9 @@ export function CreateTaskPage() {
const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null); const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const { activeOrg } = useAuth(); const [members, setMembers] = useState<OrganizationMember[]>([]);
const [assignToLoggedUser, setAssignToLoggedUser] = useState(false);
const { activeOrg, user } = useAuth();
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = !!id; const isEdit = !!id;
@ -63,8 +73,10 @@ export function CreateTaskPage() {
status: data.status, status: data.status,
projectId: data.project?.id?.toString() || "", projectId: data.project?.id?.toString() || "",
categoryId: data.category?.id?.toString() || "", categoryId: data.category?.id?.toString() || "",
assigneeUserId: data.user?.id?.toString() || "",
history: data.history || [], history: data.history || [],
}); });
setAssignToLoggedUser(Boolean(user?.email && data.user?.email === user.email));
} catch (error) { } catch (error) {
console.error("Erro ao carregar tarefa:", error); console.error("Erro ao carregar tarefa:", error);
setErrorMessage("Não foi possível carregar os dados da tarefa."); setErrorMessage("Não foi possível carregar os dados da tarefa.");
@ -72,7 +84,7 @@ export function CreateTaskPage() {
} }
loadTask(); loadTask();
} }
}, [id, isEdit]); }, [id, isEdit, user?.email]);
useEffect(() => { useEffect(() => {
async function loadProjects() { async function loadProjects() {
@ -93,10 +105,30 @@ export function CreateTaskPage() {
console.error("Erro ao carregar categorias:", error); console.error("Erro ao carregar categorias:", error);
} }
} }
async function loadMembers() {
if (!activeOrg) return;
try {
const data = await organizationService.listMembers(activeOrg.id);
setMembers(data);
} catch (error) {
console.error("Erro ao carregar membros:", error);
}
}
loadProjects(); loadProjects();
loadCategories(); loadCategories();
loadMembers();
}, [activeOrg]); }, [activeOrg]);
useEffect(() => {
if (!activeOrg) return;
if (assignToLoggedUser && user) {
const currentMember = members.find((member) => member.userEmail === user.email);
if (currentMember) {
setForm((current) => ({ ...current, assigneeUserId: currentMember.userId.toString() }));
}
}
}, [assignToLoggedUser, user, members, activeOrg]);
function updateField( function updateField(
event: ChangeEvent< event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
@ -115,12 +147,18 @@ export function CreateTaskPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
if (!activeOrg) {
throw new Error("Nenhuma organização ativa selecionada.");
}
const payload = { const payload = {
title: form.title.trim(), title: form.title.trim(),
description: form.description.trim(), description: form.description.trim(),
status: form.status, status: form.status,
organizationId: activeOrg.id,
projectId: form.projectId ? Number(form.projectId) : null, projectId: form.projectId ? Number(form.projectId) : null,
categoryId: form.categoryId ? Number(form.categoryId) : null, categoryId: form.categoryId ? Number(form.categoryId) : null,
assigneeUserId: form.assigneeUserId ? Number(form.assigneeUserId) : null,
history: form.history, history: form.history,
}; };
@ -131,6 +169,7 @@ export function CreateTaskPage() {
const response = await taskService.createTask(payload); const response = await taskService.createTask(payload);
setCreatedTask(response); setCreatedTask(response);
setForm(initialFormState); setForm(initialFormState);
setAssignToLoggedUser(false);
} }
} catch (error) { } catch (error) {
setErrorMessage( setErrorMessage(
@ -198,6 +237,10 @@ export function CreateTaskPage() {
); );
} }
const selectedAssignee = members.find(
(member) => member.userId === Number(form.assigneeUserId),
);
return ( return (
<div className="mx-auto max-w-3xl pb-20"> <div className="mx-auto max-w-3xl pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
@ -324,6 +367,69 @@ export function CreateTaskPage() {
</label> </label>
</div> </div>
<div className="space-y-4 rounded-[2rem] border border-border-soft bg-app-bg/40 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<label className="group block flex-1">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Users size={16} />
Funcionário Responsável
</div>
<select
name="assigneeUserId"
value={form.assigneeUserId}
onChange={updateField}
disabled={assignToLoggedUser}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 focus:border-brand focus:ring-4 focus:ring-brand/10"
>
<option value="">Sem responsável</option>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.userName} - {member.userEmail}
</option>
))}
</select>
</label>
<label className="flex items-center gap-3 rounded-xl border border-border-soft bg-surface px-4 py-3 text-sm font-bold text-text-main">
<input
type="checkbox"
checked={assignToLoggedUser}
onChange={(event) => {
const checked = event.target.checked;
setAssignToLoggedUser(checked);
if (!checked && user?.email && selectedAssignee?.userEmail === user.email) {
setForm((current) => ({ ...current, assigneeUserId: current.assigneeUserId }));
}
}}
className="h-4 w-4 rounded border-border-soft text-brand focus:ring-brand"
/>
Associar ao usuário logado
</label>
</div>
{selectedAssignee ? (
<div className="flex items-center gap-3 rounded-2xl border border-border-soft bg-surface p-4">
<UserAvatar
name={selectedAssignee.userName}
photo={selectedAssignee.userPhoto}
size="md"
/>
<div>
<p className="text-sm font-black text-text-main">
{selectedAssignee.userName}
</p>
<p className="text-xs font-medium text-text-muted">
{selectedAssignee.userEmail}
</p>
</div>
</div>
) : (
<p className="text-xs font-medium text-text-muted">
Esta tarefa pode ficar sem responsável.
</p>
)}
</div>
{/* Description Section */} {/* Description Section */}
<label className="group block"> <label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand"> <div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">

View file

@ -18,6 +18,13 @@ export interface UserJoinRequest {
status: 'PENDING' | 'ACTIVE' | 'REJECTED' status: 'PENDING' | 'ACTIVE' | 'REJECTED'
} }
export interface OrganizationMember {
userId: number
userName: string
userEmail: string
userPhoto?: string | null
}
export const organizationService = { export const organizationService = {
create: async (name: string): Promise<Organization> => { create: async (name: string): Promise<Organization> => {
return apiFetch('/organizations', { return apiFetch('/organizations', {
@ -46,6 +53,10 @@ export const organizationService = {
return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { method: 'PATCH' }) return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { method: 'PATCH' })
}, },
listMembers: async (organizationId: number): Promise<OrganizationMember[]> => {
return apiFetch<OrganizationMember[]>(`/organizations/${organizationId}/members`)
},
getUserRequests: async (): Promise<UserJoinRequest[]> => { getUserRequests: async (): Promise<UserJoinRequest[]> => {
return apiFetch<UserJoinRequest[]>('/organizations/my-requests') return apiFetch<UserJoinRequest[]>('/organizations/my-requests')
} }

View file

@ -26,8 +26,10 @@ export const taskService = {
async createTask(task: { async createTask(task: {
title: string title: string
description?: string description?: string
organizationId: number
projectId?: number | null projectId?: number | null
categoryId?: number | null categoryId?: number | null
assigneeUserId?: number | null
status?: TaskStatus status?: TaskStatus
}): Promise<TaskResponse> { }): Promise<TaskResponse> {
return apiFetch<TaskResponse>('/tasks', { return apiFetch<TaskResponse>('/tasks', {
@ -59,7 +61,12 @@ export const taskService = {
return apiFetch<TaskResponse>(`/tasks/${id}`) return apiFetch<TaskResponse>(`/tasks/${id}`)
}, },
async updateTask(id: number, task: Partial<TaskResponse> & { projectId?: number | null; categoryId?: number | null }): Promise<TaskResponse> { async updateTask(id: number, task: Partial<TaskResponse> & {
organizationId: number
projectId?: number | null
categoryId?: number | null
assigneeUserId?: number | null
}): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}`, { return apiFetch<TaskResponse>(`/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: task, body: task,

View file

@ -18,8 +18,10 @@ export type CreateTaskPayload = {
title: string title: string
description: string description: string
status: TaskStatus status: TaskStatus
organizationId: number
projectId: number | null projectId: number | null
categoryId: number | null categoryId: number | null
assigneeUserId: number | null
} }
export type TaskHistoryItem = { export type TaskHistoryItem = {