diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupCreationRequest.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupCreationRequest.java index 527279f2..7f456730 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupCreationRequest.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupCreationRequest.java @@ -5,6 +5,7 @@ import pl.edu.agh.project_manager.domain.enums.GroupType; import pl.edu.agh.project_manager.service.command.project.ProjectGroupCreationCommand; +import java.util.List; import java.util.UUID; public record ProjectGroupCreationRequest( @@ -15,7 +16,9 @@ public record ProjectGroupCreationRequest( String description, @NotNull(message = "Typ grupy nie może być pusty") - GroupType groupType + GroupType groupType, + + List<@NotNull UUID> projectIds ) { public ProjectGroupCreationCommand toCommand(UUID ownerId) { @@ -23,6 +26,7 @@ public ProjectGroupCreationCommand toCommand(UUID ownerId) { this.name, this.description, this.groupType, + this.projectIds, ownerId ); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectController.java index e68b3c6c..b45618e6 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectController.java @@ -8,7 +8,9 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import pl.edu.agh.project_manager.controller.dto.project.*; +import pl.edu.agh.project_manager.domain.enums.GroupType; import pl.edu.agh.project_manager.security.UserPrincipal; +import pl.edu.agh.project_manager.service.command.project.SearchProjectCommand; import pl.edu.agh.project_manager.service.project.ProjectService; import pl.edu.agh.project_manager.service.command.project.ProjectCreationCommand; @@ -36,10 +38,18 @@ public ResponseEntity createProject( @GetMapping @PreAuthorize("hasAnyRole('PROJECT_MANAGER', 'AUTHORITY', 'LINEAR_MANAGER', 'COMMON', 'ADMINISTRATOR')") public ResponseEntity> getAllProjects( - @AuthenticationPrincipal UserPrincipal userPrincipal + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(value = "query", required = false) String query, + @RequestParam(value = "groupId", required = false) UUID groupId, + @RequestParam(value = "unassignedOnly", required = false, defaultValue = "false") Boolean unassignedOnly ) { - List projects = projectService.getAccessibleProjects(userPrincipal); - return ResponseEntity.ok(projects); + SearchProjectCommand command = new SearchProjectCommand( + userPrincipal, + query, + groupId, + unassignedOnly + ); + return ResponseEntity.ok(projectService.searchProjects(command)); } @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/projectgroup/ProjectGroup.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/projectgroup/ProjectGroup.java index 00434211..dd29f78a 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/projectgroup/ProjectGroup.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/projectgroup/ProjectGroup.java @@ -40,4 +40,9 @@ public class ProjectGroup { @OneToMany(mappedBy = "projectGroup") @Builder.Default private List projects = new ArrayList<>(); + + public void addProject(Project project) { + this.projects.add(project); + project.setProjectGroup(this); + } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectRepository.java index 7f5b90a0..d4f169f8 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectRepository.java @@ -1,5 +1,6 @@ package pl.edu.agh.project_manager.repository.project; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -41,4 +42,7 @@ public interface ProjectRepository extends JpaRepository { @EntityGraph(attributePaths = {"projectManager", "projectManager.qualifications"}) @Query("SELECT DISTINCT p FROM Project p JOIN p.members m WHERE m.user.id = :userId") List findAllByMemberId(@Param("userId") UUID userId); + + @EntityGraph(attributePaths = {"projectManager"}) + List findAll(Specification spec); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/ProjectGroupCreationCommand.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/ProjectGroupCreationCommand.java index 5022087c..6a578309 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/ProjectGroupCreationCommand.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/ProjectGroupCreationCommand.java @@ -2,12 +2,14 @@ import pl.edu.agh.project_manager.domain.enums.GroupType; +import java.util.List; import java.util.UUID; public record ProjectGroupCreationCommand( String name, String description, GroupType groupType, + List projectIds, UUID ownerId ) { } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/SearchProjectCommand.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/SearchProjectCommand.java new file mode 100644 index 00000000..aa45c8ce --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/SearchProjectCommand.java @@ -0,0 +1,17 @@ +package pl.edu.agh.project_manager.service.command.project; + +import pl.edu.agh.project_manager.domain.enums.UserRole; +import pl.edu.agh.project_manager.security.UserPrincipal; + +import java.util.UUID; + +public record SearchProjectCommand( + UserPrincipal user, + String query, + UUID groupId, + Boolean unassignedOnly +) { + public SearchProjectCommand toCommand(UserPrincipal user, String query, UUID groupId, Boolean unassignedOnly) { + return new SearchProjectCommand(user, query, groupId, unassignedOnly); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectService.java index 6543003c..1db10fe6 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectService.java @@ -1,7 +1,10 @@ package pl.edu.agh.project_manager.service.project; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import pl.edu.agh.project_manager.controller.dto.project.ProjectMembersResponse; import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; @@ -10,6 +13,7 @@ import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.entity.project.ProjectMilestone; +import pl.edu.agh.project_manager.domain.enums.GroupType; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.project.ProjectRepository; @@ -18,9 +22,11 @@ import pl.edu.agh.project_manager.service.command.project.ProjectCreationCommand; import pl.edu.agh.project_manager.service.command.project.RiskCommand; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import pl.edu.agh.project_manager.domain.enums.UserRole; +import pl.edu.agh.project_manager.service.command.project.SearchProjectCommand; import pl.edu.agh.project_manager.service.projectgroup.ProjectGroupsService; import pl.edu.agh.project_manager.service.user.UserService; @@ -56,23 +62,6 @@ public UUID createProject(ProjectCreationCommand command) { return savedProject.getId(); } - @Transactional - public List getAccessibleProjects(UserPrincipal userPrincipal) { - - UserRole role = userPrincipal.userRole(); - - List projects = switch (role) { - case ADMINISTRATOR, AUTHORITY -> projectRepository.findAll(); - case PROJECT_MANAGER -> projectRepository.findAllByProjectManagerId(userPrincipal.userId()); - case LINEAR_MANAGER, COMMON -> projectRepository.findAllByMemberId(userPrincipal.userId()); - default -> List.of(); - }; - - return projects.stream() - .map(ProjectResponse::from) - .toList(); - } - public ProjectResponse getProject(UUID projectId) { Project project = projectRepository.findByIdWithManager(projectId) .orElseThrow(() -> new ApplicationException( @@ -154,4 +143,30 @@ public Project getProjectEntityOrThrow(UUID projectId) { .orElseThrow(() -> new ApplicationException(ApiErrorCode.PROJECT_NOT_FOUND, "Cannot find project: " + projectId)); } + @Transactional + public List searchProjects(SearchProjectCommand command) { + Specification spec = Specification + .where(ProjectSpecification.accessibleByUser(command.user())) + .and(buildSearchFilter(command)); + + return projectRepository.findAll(spec).stream() + .map(ProjectResponse::from) + .toList(); + } + + private Specification buildSearchFilter(SearchProjectCommand command) { + Specification spec = (root, query, cb) -> cb.conjunction(); + + if (command.query() != null && !command.query().isBlank()) { + spec = spec.and(ProjectSpecification.withSearchPattern(command.query())); + } + + if (command.groupId() != null) { + spec = spec.and(ProjectSpecification.inGroup(command.groupId())); + } else if (Boolean.TRUE.equals(command.unassignedOnly())) { + spec = spec.and(ProjectSpecification.unassigned()); + } + + return spec; + } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectSpecification.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectSpecification.java new file mode 100644 index 00000000..1d27013e --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectSpecification.java @@ -0,0 +1,41 @@ +package pl.edu.agh.project_manager.service.project; + +import jakarta.persistence.criteria.Join; +import org.springframework.data.jpa.domain.Specification; +import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.security.UserPrincipal; + +import java.util.UUID; + +public class ProjectSpecification { + + public static Specification withSearchPattern(String query) { + return (root, query1, cb) -> { + String pattern = "%" + query.toLowerCase() + "%"; + return cb.like(cb.lower(root.get("title")), pattern); + }; + } + + public static Specification accessibleByUser(UserPrincipal user) { + return (root, query, cb) -> switch (user.userRole()) { + case PROJECT_MANAGER -> + cb.equal(root.get("projectManager").get("id"), user.userId()); + case LINEAR_MANAGER, COMMON -> { + Join membersJoin = root.join("members"); + query.distinct(true); + yield cb.equal(membersJoin.get("id"), user.userId()); + } + case ADMINISTRATOR, AUTHORITY -> cb.conjunction(); + }; + } + + public static Specification inGroup(UUID groupId) { + return (root, query, cb) -> cb.equal(root.get("projectGroup").get("id"), groupId); + } + + public static Specification unassigned() { + return (root, query, cb) -> cb.isNull(root.get("projectGroup")); + } + +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupsService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupsService.java index 2dd4ba77..5617a53e 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupsService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupsService.java @@ -2,15 +2,18 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.project_group.AllGroupsResponse; import pl.edu.agh.project_manager.controller.dto.project_group.GroupOwnerResponse; import pl.edu.agh.project_manager.controller.dto.project_group.SingleGroupDetailsResponse; import pl.edu.agh.project_manager.controller.dto.project_group.SingleGroupResponse; +import pl.edu.agh.project_manager.domain.entity.project.Project; import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.GroupType; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; +import pl.edu.agh.project_manager.repository.project.ProjectRepository; import pl.edu.agh.project_manager.repository.projectgroup.ProjectGroupRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; import pl.edu.agh.project_manager.service.command.project.ProjectGroupCreationCommand; @@ -24,6 +27,7 @@ public class ProjectGroupsService { private final ProjectGroupRepository projectGroupRepository; private final UserRepository userRepository; + private final ProjectRepository projectRepository; public AllGroupsResponse getAllGroups() { return new AllGroupsResponse(getWalletGroups(), getProgramGroups()); @@ -56,6 +60,7 @@ public SingleGroupDetailsResponse getGroupById(UUID id) { ); } + @Transactional public UUID createGroup(ProjectGroupCreationCommand command) { User owner = userRepository.findById(command.ownerId()) .orElseThrow(() -> new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user with id: " + command.ownerId())); @@ -63,6 +68,7 @@ public UUID createGroup(ProjectGroupCreationCommand command) { ProjectGroup projectGroup = buildProjectGroups(command, owner); ProjectGroup savedGroup = projectGroupRepository.save(projectGroup); + addProjectToProjectGroup(command.projectIds(), projectGroup); return savedGroup.getId(); } @@ -87,4 +93,14 @@ public ProjectGroup getProjectGroupOrThrow(UUID groupId) { return projectGroupRepository.findById(groupId) .orElseThrow(() -> new ApplicationException(ApiErrorCode.PROJECT_GROUP_NOT_FOUND, "Cannot find group: " + groupId)); } + + private void addProjectToProjectGroup(List projectIds, ProjectGroup projectGroups) { + List projects = projectRepository.findAllById(projectIds); + + if (projects.size() != projectIds.size()) { + throw new ApplicationException(ApiErrorCode.PROJECT_NOT_FOUND, "Cannot find all projects with provided ids"); + } + + projects.forEach(projectGroups::addProject); + } } diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectServiceTest.java index 6ffd4552..fd36abb6 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectServiceTest.java @@ -6,6 +6,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.jpa.domain.Specification; import pl.edu.agh.project_manager.domain.entity.project.Project; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.UserRole; @@ -13,6 +14,7 @@ import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.project.ProjectRepository; import pl.edu.agh.project_manager.security.UserPrincipal; +import pl.edu.agh.project_manager.service.command.project.SearchProjectCommand; import pl.edu.agh.project_manager.service.projectgroup.ProjectGroupsService; import pl.edu.agh.project_manager.service.user.UserService; import pl.edu.agh.project_manager.service.command.project.MilestoneCommand; @@ -99,17 +101,17 @@ void getAccessibleProjects_Administrator() { // Given UUID adminId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(adminId, UserRole.ADMINISTRATOR); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); - when(projectRepository.findAll()).thenReturn(List.of( + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A"), createTestProject(UUID.randomUUID(), "Projekt B") )); // When - var result = projectService.getAccessibleProjects(principal); + var result = projectService.searchProjects(searchCommand); // Then assertThat(result).hasSize(2); - verify(projectRepository).findAll(); } @Test @@ -118,17 +120,17 @@ void getAccessibleProjects_ProjectManager() { // Given UUID pmId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(pmId, UserRole.PROJECT_MANAGER); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); - when(projectRepository.findAllByProjectManagerId(pmId)).thenReturn(List.of( + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A") )); // When - var result = projectService.getAccessibleProjects(principal); + var result = projectService.searchProjects(searchCommand); // Then assertThat(result).hasSize(1); - verify(projectRepository).findAllByProjectManagerId(pmId); } @Test @@ -137,17 +139,17 @@ void getAccessibleProjects_CommonUser() { // Given UUID userId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(userId, UserRole.COMMON); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); - when(projectRepository.findAllByMemberId(userId)).thenReturn(List.of( + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A") )); // When - var result = projectService.getAccessibleProjects(principal); + var result = projectService.searchProjects(searchCommand); // Then assertThat(result).hasSize(1); - verify(projectRepository).findAllByMemberId(userId); } @Test @@ -172,7 +174,6 @@ void getProjectMembers_Success() { // Then assertThat(response).isNotNull(); assertThat(response.sponsors()).hasSize(1); - verify(projectRepository).findByIdWithAllMembers(projectId); } private ProjectCreationCommand createBasicCommand(UUID managerId) { diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupServiceTest.java index fa246a37..360ee143 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/projectgroup/ProjectGroupServiceTest.java @@ -13,10 +13,12 @@ import pl.edu.agh.project_manager.domain.enums.GroupType; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; +import pl.edu.agh.project_manager.repository.project.ProjectRepository; import pl.edu.agh.project_manager.repository.projectgroup.ProjectGroupRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; import pl.edu.agh.project_manager.service.command.project.ProjectGroupCreationCommand; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,6 +37,9 @@ class ProjectGroupServiceTest { @Mock private UserRepository userRepository; + @Mock + private ProjectRepository projectRepository; + @InjectMocks private ProjectGroupsService projectGroupsService; @@ -119,13 +124,14 @@ void createGroup_Success() { // Given UUID userId = UUID.randomUUID(); ProjectGroupCreationCommand command = new ProjectGroupCreationCommand( - "New Program", "Desc", GroupType.PROGRAM, userId + "New Program", "Desc", GroupType.PROGRAM, new ArrayList<>(), userId ); User owner = User.builder().id(userId).build(); ProjectGroup savedGroup = ProjectGroup.builder().id(UUID.randomUUID()).build(); when(userRepository.findById(userId)).thenReturn(Optional.of(owner)); when(projectGroupRepository.save(any(ProjectGroup.class))).thenReturn(savedGroup); + when(projectRepository.findAllById(new ArrayList<>())).thenReturn(new ArrayList<>()); // When UUID resultId = projectGroupsService.createGroup(command); @@ -141,7 +147,7 @@ void createGroup_UserNotFound() { // Given UUID userId = UUID.randomUUID(); ProjectGroupCreationCommand command = new ProjectGroupCreationCommand( - "New Program", "Desc", GroupType.PROGRAM, userId + "New Program", "Desc", GroupType.PROGRAM, new ArrayList<>(), userId ); when(userRepository.findById(userId)).thenReturn(Optional.empty()); diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 45268bf6..5d25a549 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -22,8 +22,6 @@ export const ENDPOINTS = { }, PROJECT: { BASE: '/projects', - LIST: '/projects', - CREATE: '/projects', DETAIL: (id: string) => `/projects/${id}`, RISK: { LIST: (projectId: string) => `/projects/${projectId}/risks` diff --git a/frontend/src/features/project/project.hooks.ts b/frontend/src/features/project/project.hooks.ts index 80e81e45..5e15bffc 100644 --- a/frontend/src/features/project/project.hooks.ts +++ b/frontend/src/features/project/project.hooks.ts @@ -46,6 +46,14 @@ export const useProjectMembers = (id: string) => { enabled: !!id }); }; +export const useSearchProjectsWithinGroup = (query: string) => { + return useQuery({ + queryKey: PROJECT_KEYS.search(query), + queryFn: () => projectService.searchProjectsWithinGroup(query), + enabled: query.length >= 2 + }); +}; + export const useProjectRolesStatus = (projectId: string) => { return useQuery({ diff --git a/frontend/src/features/project/project.keys.ts b/frontend/src/features/project/project.keys.ts index 3034ecb9..8aa4a308 100644 --- a/frontend/src/features/project/project.keys.ts +++ b/frontend/src/features/project/project.keys.ts @@ -3,6 +3,7 @@ export const PROJECT_KEYS = { list: () => [...PROJECT_KEYS.all, 'list'] as const, details: () => [...PROJECT_KEYS.all, 'detail'] as const, detail: (id: string) => [...PROJECT_KEYS.details(), id] as const, + search: (query: string) => [...PROJECT_KEYS.all, 'search', query] as const, risks: (projectId: string) => [...PROJECT_KEYS.detail(projectId), 'risks'] as const, members: (projectId: string) => [...PROJECT_KEYS.detail(projectId), 'members'] as const, diff --git a/frontend/src/features/project/project.service.ts b/frontend/src/features/project/project.service.ts index 1ce93805..9e7f07a9 100644 --- a/frontend/src/features/project/project.service.ts +++ b/frontend/src/features/project/project.service.ts @@ -43,5 +43,15 @@ export const projectService = { createEmployeeAssignment: async (data: EmployeeAssignmentRequest): Promise => { await api.post(ENDPOINTS.APPROVALS.ASSIGNMENTS, data); - } + }, + + searchProjectsWithinGroup: async (searchTerm: string): Promise => { + const response = await api.get(ENDPOINTS.PROJECT.BASE, { + params: { + query: searchTerm, + unassignedOnly: true + } + }); + return response.data; + } }; \ No newline at end of file diff --git a/frontend/src/features/project_group/components/CreateProjectGroupForm.tsx b/frontend/src/features/project_group/components/CreateProjectGroupForm.tsx new file mode 100644 index 00000000..970a7cb4 --- /dev/null +++ b/frontend/src/features/project_group/components/CreateProjectGroupForm.tsx @@ -0,0 +1,63 @@ +import { FormProvider, useForm} from "react-hook-form"; +import type { CreateProjectGroupFormData } from "../project_group.schema.ts"; +import { type ProjectGroupCreationRequest } from "../project_group.types.ts"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useDebounce } from "use-debounce"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PATHS } from "@/routes/paths.ts"; +import { CreateProjectGroupFormSchema } from "../project_group.schema.ts"; +import { useSearchProjectsWithinGroup } from "@/features/project/project.hooks.ts"; +import { useCreateProjectGroup } from "../project_group.hooks.ts"; +import { CreateProjectGroupFormView } from "./CreateProjectGroupForm.view.tsx"; +import { ProjectGroupType } from "../project_group.types.ts"; + + +export const CreateProjectGroupForm = () => { + const methods = useForm({ + resolver: zodResolver(CreateProjectGroupFormSchema), + defaultValues: { + name: "", + description: "", + groupType: ProjectGroupType.WALLET, + projectIds: [], + } + }); + + const [projectQuery, setProjectQuery] = useState(""); + const [projectQueryValue] = useDebounce(projectQuery, 300); + + const { data: foundProjects = [] } = useSearchProjectsWithinGroup(projectQueryValue); + + const mutation = useCreateProjectGroup(); + const navigate = useNavigate(); + + const onSubmit = (data: CreateProjectGroupFormData) => { + const payload: ProjectGroupCreationRequest = { + name: data.name, + description: data.description, + groupType: data.groupType, + projectIds: data.projectIds, + }; + + mutation.mutate(payload, { + onSuccess: () => { + methods.reset(); + navigate(PATHS.ROOT); + } + } + ); + }; + + return ( + + + + ) +} + +export default CreateProjectGroupForm; \ No newline at end of file diff --git a/frontend/src/features/project_group/components/CreateProjectGroupForm.view.tsx b/frontend/src/features/project_group/components/CreateProjectGroupForm.view.tsx new file mode 100644 index 00000000..703fc0c5 --- /dev/null +++ b/frontend/src/features/project_group/components/CreateProjectGroupForm.view.tsx @@ -0,0 +1,179 @@ +import type { ProjectDetailsResponse } from "@/features/project/project.types.ts"; +import { PROJECT_GROUP_TYPE_LABELS, PROJECT_GROUP_TYPE_VALUES } from "../project_group.types.ts"; +import { useFormContext, type SubmitHandler } from "react-hook-form"; +import { useState } from "react"; +import type { CreateProjectGroupFormData } from "../project_group.schema.ts"; + +interface CreateProjectGroupFormProps { + onSubmit: SubmitHandler; + foundProjects: ProjectDetailsResponse[]; + onSearchProjects: (query: string) => void; +} + +export const CreateProjectGroupFormView = ({ onSubmit, foundProjects, onSearchProjects }: CreateProjectGroupFormProps) => { + const { + register, + handleSubmit, + formState: { errors }, + watch, + getValues, + setValue, + } = useFormContext(); + + const [searchInput, setSearchInput] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedProjectsById, setSelectedProjectsById] = useState>({}); + + const selectedProjectIds = watch("projectIds") ?? []; + + const handleAddProject = (project: ProjectDetailsResponse) => { + const currentIds = getValues("projectIds") ?? []; + + if (!currentIds.includes(project.id)) { + setValue("projectIds", [...currentIds, project.id], { + shouldValidate: true, + shouldDirty: true, + }); + } + + setSelectedProjectsById((prev) => ({ + ...prev, + [project.id]: project, + })); + + setSearchInput(""); + onSearchProjects(""); + setIsDropdownOpen(false); + }; + + const handleRemoveProject = (projectId: string) => { + const currentIds = getValues("projectIds") ?? []; + + setValue( + "projectIds", + currentIds.filter((id) => id !== projectId), + { + shouldValidate: true, + shouldDirty: true, + }, + ); + }; + + return ( +
+

Tworzenie portfela/programu

+
+
+ + +
+ + {errors.name &&

{errors.name.message}

} + +
+ +