From f5b58979f5d197069d5616e7f757faad1611e592 Mon Sep 17 00:00:00 2001 From: FaFikPL367 Date: Tue, 5 May 2026 20:17:35 +0200 Subject: [PATCH 1/6] Add form to create new group and connect it to backend --- .../controller/ProjectManagement.java | 8 + .../ProjectGroupCreationRequest.java | 6 +- .../domain/entity/ProjectGroups.java | 5 + .../repository/ProjectRepository.java | 2 + .../service/ProjectGroupsService.java | 17 ++ .../service/ProjectService.java | 9 + .../project/ProjectGroupCreationCommand.java | 2 + frontend/src/api/endpoints.ts | 1 + .../src/features/project/project.hooks.ts | 8 + frontend/src/features/project/project.keys.ts | 1 + .../src/features/project/project.service.ts | 9 + .../components/CreateProjectGroupForm.tsx | 54 ++++++ .../CreateProjectGroupForm.view.tsx | 179 ++++++++++++++++++ .../project_group/project_group.hooks.ts | 12 +- .../project_group/project_group.schema.ts | 11 ++ .../project_group/project_group.service.ts | 7 +- .../project_group/project_group.types.ts | 23 +++ .../project_group/project_groups.keys.ts | 3 +- frontend/src/pages/CreateProjectGroupPage.tsx | 11 ++ frontend/src/routes/AppRoutes.tsx | 6 +- frontend/src/routes/navigation.ts | 6 + frontend/src/routes/paths.ts | 1 + 22 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/project_group/components/CreateProjectGroupForm.tsx create mode 100644 frontend/src/features/project_group/components/CreateProjectGroupForm.view.tsx create mode 100644 frontend/src/features/project_group/project_group.schema.ts create mode 100644 frontend/src/pages/CreateProjectGroupPage.tsx diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/ProjectManagement.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/ProjectManagement.java index a3ba3c97..091938f8 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/ProjectManagement.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/ProjectManagement.java @@ -93,6 +93,14 @@ public ResponseEntity> getAllProjects( List projects = projectService.getAllProjects(userPrincipal); return ResponseEntity.ok(projects); } + + @GetMapping("/search") + @PreAuthorize("hasAnyRole('PROJECT_MANAGER', 'AUTHORITY')") + public ResponseEntity> searchProject( + @RequestParam("search") String query + ) { + return ResponseEntity.ok(projectService.searchProjects(query)); + } } 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..80da6372 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 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/domain/entity/ProjectGroups.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/ProjectGroups.java index 6ac0e284..1d4db9e3 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/ProjectGroups.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/ProjectGroups.java @@ -38,4 +38,9 @@ public class ProjectGroups { @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/ProjectRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/ProjectRepository.java index f0781be1..0ed993af 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/ProjectRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/ProjectRepository.java @@ -36,4 +36,6 @@ 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); + + List findByTitleContainingIgnoreCaseAndProjectGroupIsNull(String title); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectGroupsService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectGroupsService.java index 7f8ba18a..ed33825f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectGroupsService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectGroupsService.java @@ -2,16 +2,19 @@ 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; import pl.edu.agh.project_manager.domain.entity.ProjectGroups; import pl.edu.agh.project_manager.domain.entity.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.ProjectGroupsRepository; +import pl.edu.agh.project_manager.repository.ProjectRepository; import pl.edu.agh.project_manager.repository.UserRepository; import pl.edu.agh.project_manager.service.command.project.ProjectGroupCreationCommand; @@ -24,6 +27,7 @@ public class ProjectGroupsService { private final ProjectGroupsRepository projectGroupsRepository; private final UserRepository userRepository; + private final ProjectRepository projectRepository; public AllGroupsResponse getAllGroups() { return new AllGroupsResponse(getWalletGroups(), getProgramGroups()); @@ -56,12 +60,15 @@ 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())); ProjectGroups projectGroups = buildProjectGroups(command, owner); + addProjectToProjectGroup(command.projectIds(), projectGroups); + ProjectGroups savedGroup = projectGroupsRepository.save(projectGroups); return savedGroup.getId(); @@ -82,4 +89,14 @@ private ProjectGroups buildProjectGroups(ProjectGroupCreationCommand command, Us .owner(owner) .build(); } + + private void addProjectToProjectGroup(List projectIds, ProjectGroups 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/main/java/pl/edu/agh/project_manager/service/ProjectService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectService.java index a3a8d73d..4634f594 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/ProjectService.java @@ -261,4 +261,13 @@ private void addCommitteesToProject(Project project, List committee) { committeeUsers.forEach(project::addCommittee); } + + @Transactional + public List searchProjects(String query) { + List project = projectRepository.findByTitleContainingIgnoreCaseAndProjectGroupIsNull(query); + + return project.stream() + .map(ProjectResponse::from) + .toList(); + } } 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/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index c675e3b6..46c5106d 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -26,6 +26,7 @@ export const ENDPOINTS = { RISK: { LIST: (projectId: string) => `/projects/${projectId}/risk` }, + SEARCH_PROJECTS: '/projects/search' }, PROJECT_GROUP: { LIST_ALL: '/groups', diff --git a/frontend/src/features/project/project.hooks.ts b/frontend/src/features/project/project.hooks.ts index f6d0e79d..fd2ad5fb 100644 --- a/frontend/src/features/project/project.hooks.ts +++ b/frontend/src/features/project/project.hooks.ts @@ -27,5 +27,13 @@ export const useCreateProject = () => { }); }; +export const useSearchProjects = (query: string) => { + return useQuery({ + queryKey: PROJECT_KEYS.search(query), + queryFn: () => projectService.searchProjects(query), + enabled: query.length >= 2 + }); +}; + diff --git a/frontend/src/features/project/project.keys.ts b/frontend/src/features/project/project.keys.ts index b697d1aa..13803136 100644 --- a/frontend/src/features/project/project.keys.ts +++ b/frontend/src/features/project/project.keys.ts @@ -3,4 +3,5 @@ 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 }; \ No newline at end of file diff --git a/frontend/src/features/project/project.service.ts b/frontend/src/features/project/project.service.ts index d441f13c..28e7ecf3 100644 --- a/frontend/src/features/project/project.service.ts +++ b/frontend/src/features/project/project.service.ts @@ -21,5 +21,14 @@ export const projectService = { getRisks: async (projectId: string): Promise => { const response = await api.get(ENDPOINTS.PROJECT.RISK.LIST(projectId)); return response.data; + }, + + searchProjects: async (searchTerm: string): Promise => { + const response = await api.get(ENDPOINTS.PROJECT.SEARCH_PROJECTS, { + params: { + search: searchTerm + } + }); + 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..05b1dada --- /dev/null +++ b/frontend/src/features/project_group/components/CreateProjectGroupForm.tsx @@ -0,0 +1,54 @@ +import { useForm} from "react-hook-form"; +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 { useSearchProjects } from "@/features/project/project.hooks.ts"; +import { useCreateProjectGroup } from "../project_group.hooks.ts"; +import { CreateProjectGroupFormView } from "./CreateProjectGroupForm.view.tsx"; + + +export const CreateProjectGroupForm = () => { + const methods = useForm({ + resolver: zodResolver(CreateProjectGroupFormSchema), + defaultValues: { + name: "", + description: "", + groupType: "WALLET", + projectIds: [], + } + }); + + const [projectQuery, setProjectQuery] = useState(""); + const [projectQueryValue] = useDebounce(projectQuery, 300); + + const { data: foundProjects = [] } = useSearchProjects(projectQueryValue); + + const mutation = useCreateProjectGroup(); + const navigate = useNavigate(); + + const onSubmit = (data: ProjectGroupCreationRequest) => { + mutation.mutate(data, { + 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..f57a9a2b --- /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 type { ProjectGroupCreationRequest } from "../project_group.types.ts"; +import { PROJECT_GROUP_TYPE_LABELS, PROJECT_GROUP_TYPE_VALUES } from "../project_group.types.ts"; +import type { SubmitHandler, UseFormReturn } from "react-hook-form"; +import { useState } from "react"; + +interface CreateProjectGroupFormProps { + methods: UseFormReturn; + onSubmit: SubmitHandler; + foundProjects: ProjectDetailsResponse[]; + onSearchProjects: (query: string) => void; +} + +export const CreateProjectGroupFormView = ({ methods, onSubmit, foundProjects, onSearchProjects }: CreateProjectGroupFormProps) => { + const { + register, + formState: { errors }, + watch, + getValues, + setValue, + } = methods; + + 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}

} + +
+ +