diff --git a/.gitignore b/.gitignore index 851ad2c1..f5945564 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ logs/ # ========================= coverage/ +# Claude Code local settings +.claude/settings.local.json + diff --git a/backend/project-manager/build.gradle b/backend/project-manager/build.gradle index ad4ed91f..37a84301 100644 --- a/backend/project-manager/build.gradle +++ b/backend/project-manager/build.gradle @@ -39,12 +39,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // JSON Web Token (JWT) implementation 'io.jsonwebtoken:jjwt-api:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + // CSV & PDF + implementation 'com.opencsv:opencsv:5.9' + implementation 'com.openhtmltopdf:openhtmltopdf-core:1.0.10' + implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' + // TOOLS compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' @@ -68,4 +74,4 @@ tasks.named('test') { jar { enabled = false -} +} \ No newline at end of file diff --git a/backend/project-manager/gradle.properties b/backend/project-manager/gradle.properties new file mode 100644 index 00000000..2f97dafb --- /dev/null +++ b/backend/project-manager/gradle.properties @@ -0,0 +1 @@ +org.gradle.java.installations.auto-download=true \ No newline at end of file diff --git a/backend/project-manager/settings.gradle b/backend/project-manager/settings.gradle index 0a9425cc..f4c17051 100644 --- a/backend/project-manager/settings.gradle +++ b/backend/project-manager/settings.gradle @@ -1 +1,5 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + rootProject.name = 'project-manager' diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/config/DevDataInitializer.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/config/DevDataInitializer.java index 58877550..f4eaabe0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/config/DevDataInitializer.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/config/DevDataInitializer.java @@ -7,11 +7,27 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.password.PasswordEncoder; +import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; +import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; +import pl.edu.agh.project_manager.domain.entity.user.Qualification; +import pl.edu.agh.project_manager.domain.entity.user.Skill; import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.AssignmentStatus; +import pl.edu.agh.project_manager.domain.enums.GroupType; +import pl.edu.agh.project_manager.domain.enums.QualificationStatus; import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.repository.project.ProjectAssignmentRepository; +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.QualificationRepository; +import pl.edu.agh.project_manager.repository.user.SkillRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; +import java.time.LocalDate; +import java.util.List; + @Configuration @Profile("dev") @RequiredArgsConstructor @@ -19,6 +35,11 @@ public class DevDataInitializer implements CommandLineRunner { private final UserRepository userRepository; + private final SkillRepository skillRepository; + private final QualificationRepository qualificationRepository; + private final ProjectRepository projectRepository; + private final ProjectAssignmentRepository assignmentRepository; + private final ProjectGroupRepository projectGroupRepository; private final PasswordEncoder passwordEncoder; @Value("${application.admin.username}") @@ -28,78 +49,382 @@ public class DevDataInitializer implements CommandLineRunner { @Override public void run(String... args) { - log.info("Checking and initializing DEV users..."); + log.info("DEV data seeding started"); + + BaseUsers base = seedBaseUsers(); + ExtendedUsers extended = seedExtendedUsers(base); + seedQualifications(base, extended); + Groups groups = seedProjectGroups(base); + Projects projects = seedProjects(base, extended, groups); + seedAssignments(base, extended, projects); + + log.info("DEV data seeding finished"); + } + + private record BaseUsers(User admin, User authority, User linearManager, User projectManager, User common) {} + + private BaseUsers seedBaseUsers() { + String defaultPassword = passwordEncoder.encode("password123"); + + User admin = createUserIfNotExists( + adminUsername, passwordEncoder.encode(adminPassword), + "Super", "Admin", UserRole.ADMINISTRATOR, null + ); + User authority = createUserIfNotExists( + "authority@dev.com", defaultPassword, + "Jan", "Władza", UserRole.AUTHORITY, null + ); + User linearManager = createUserIfNotExists( + "linear@dev.com", defaultPassword, + "Piotr", "Liniowy", UserRole.LINEAR_MANAGER, null + ); + User projectManager = createUserIfNotExists( + "pm@dev.com", defaultPassword, + "Anna", "Manager", UserRole.PROJECT_MANAGER, null + ); + User common = createUserIfNotExists( + "common@dev.com", defaultPassword, + "Maciej", "Pracownik", UserRole.COMMON, linearManager + ); + + return new BaseUsers(admin, authority, linearManager, projectManager, common); + } + + private record ExtendedUsers( + User linearManagerTwo, + User projectManagerTwo, + User projectManagerThree, + User commonTwo, User commonThree, User commonFour, + User commonFive, User commonSix, User commonSeven + ) {} + private ExtendedUsers seedExtendedUsers(BaseUsers base) { String defaultPassword = passwordEncoder.encode("password123"); - // ADMIN - createUserIfNotExists( - adminUsername, - passwordEncoder.encode(adminPassword), - "Super", - "Admin", - UserRole.ADMINISTRATOR + ensureSupervisor(base.linearManager(), base.authority()); + ensureSupervisor(base.projectManager(), base.linearManager()); + + User linearManagerTwo = createUserIfNotExists( + "lm2@dev.com", defaultPassword, + "Ewa", "Wiśniewska", UserRole.LINEAR_MANAGER, base.authority() ); - // PROJECT MANAGER - createUserIfNotExists( - "pm@dev.com", - defaultPassword, - "Anna", - "Manager", - UserRole.PROJECT_MANAGER + User projectManagerTwo = createUserIfNotExists( + "pm2@dev.com", defaultPassword, + "Krzysztof", "Zieliński", UserRole.PROJECT_MANAGER, linearManagerTwo + ); + User projectManagerThree = createUserIfNotExists( + "pm3@dev.com", defaultPassword, + "Magdalena", "Wójcik", UserRole.PROJECT_MANAGER, base.linearManager() ); - // LINEAR MANAGER - User linearManager = createUserIfNotExists( - "linear@dev.com", - defaultPassword, - "Piotr", - "Liniowy", - UserRole.LINEAR_MANAGER + User commonTwo = createUserIfNotExists( + "common2@dev.com", defaultPassword, + "Katarzyna", "Nowak", UserRole.COMMON, base.linearManager() + ); + User commonThree = createUserIfNotExists( + "common3@dev.com", defaultPassword, + "Tomasz", "Kowalski", UserRole.COMMON, base.linearManager() + ); + User commonFour = createUserIfNotExists( + "common4@dev.com", defaultPassword, + "Aleksandra", "Lewandowska", UserRole.COMMON, base.linearManager() + ); + + User commonFive = createUserIfNotExists( + "common5@dev.com", defaultPassword, + "Bartosz", "Kamiński", UserRole.COMMON, linearManagerTwo + ); + User commonSix = createUserIfNotExists( + "common6@dev.com", defaultPassword, + "Natalia", "Szymańska", UserRole.COMMON, linearManagerTwo + ); + User commonSeven = createUserIfNotExists( + "common7@dev.com", defaultPassword, + "Paweł", "Dąbrowski", UserRole.COMMON, linearManagerTwo + ); + + return new ExtendedUsers( + linearManagerTwo, projectManagerTwo, projectManagerThree, + commonTwo, commonThree, commonFour, + commonFive, commonSix, commonSeven + ); + } + + private void seedQualifications(BaseUsers base, ExtendedUsers ext) { + addAcceptedQualifications(base.common(), List.of("Java", "Spring Boot", "PostgreSQL", "Docker")); + addWaitingQualifications(base.common(), List.of("Kubernetes", "AWS")); + addRejectedQualifications(base.common(), List.of("COBOL")); + + addAcceptedQualifications(ext.commonTwo(), List.of("React", "TypeScript", "TailwindCSS")); + addWaitingQualifications(ext.commonTwo(), List.of("Next.js")); + + addAcceptedQualifications(ext.commonThree(), List.of("Python", "Machine Learning", "TensorFlow")); + addWaitingQualifications(ext.commonThree(), List.of("PyTorch")); + + addAcceptedQualifications(ext.commonFour(), List.of("UX Design", "Figma", "Design Systems")); + + addAcceptedQualifications(ext.commonFive(), List.of("DevOps", "Linux", "Bash")); + addWaitingQualifications(ext.commonFive(), List.of("Terraform")); + + addAcceptedQualifications(ext.commonSix(), List.of("QA Manual", "QA Automation", "Cypress")); + + addAcceptedQualifications(ext.commonSeven(), List.of("Java", "Spring Boot")); + addWaitingQualifications(ext.commonSeven(), List.of("Kafka")); + } + + private record Groups(ProjectGroup digitalization, ProjectGroup research) {} + + private Groups seedProjectGroups(BaseUsers base) { + ProjectGroup digitalization = createProjectGroupIfNotExists( + "Cyfryzacja Wydziału", + "Portfel projektów modernizujących infrastrukturę cyfrową wydziału.", + base.authority(), GroupType.WALLET + ); + ProjectGroup research = createProjectGroupIfNotExists( + "Program Badawczy 2026", + "Program wspierający projekty badawcze realizowane w 2026 roku.", + base.authority(), GroupType.PROGRAM + ); + return new Groups(digitalization, research); + } + + private record Projects( + Project recruitment, Project alumni, Project mobileApp, + Project elearning, Project libraryMigration, + Project aiResearch, Project graphResearch, + Project lan, Project securityAudit + ) {} + + private Projects seedProjects(BaseUsers base, ExtendedUsers ext, Groups groups) { + Project recruitment = createProjectIfNotExists( + "System rekrutacyjny", + "Nowa platforma do obsługi procesu rekrutacji studentów.", + base.projectManager(), + LocalDate.now().minusMonths(2), LocalDate.now().plusMonths(4), + true, groups.digitalization() + ); + Project alumni = createProjectIfNotExists( + "Portal absolwenta", + "Portal społeczności absolwentów wydziału z bazą ofert pracy.", + base.projectManager(), + LocalDate.now().minusMonths(6), LocalDate.now().minusMonths(1), + false, groups.digitalization() + ); + Project mobileApp = createProjectIfNotExists( + "Aplikacja mobilna studenta", + "Mobilna aplikacja iOS/Android z planem zajęć i ocenami.", + base.projectManager(), + LocalDate.now().minusWeeks(3), LocalDate.now().plusMonths(7), + true, groups.digitalization() + ); + + Project elearning = createProjectIfNotExists( + "Platforma e-learningowa", + "Wewnętrzna platforma e-learningowa dla pracowników wydziału.", + ext.projectManagerTwo(), + LocalDate.now().minusMonths(4), LocalDate.now().plusMonths(2), + true, groups.digitalization() + ); + Project libraryMigration = createProjectIfNotExists( + "Migracja biblioteki", + "Migracja systemu bibliotecznego do nowej infrastruktury chmurowej.", + ext.projectManagerTwo(), + LocalDate.now().minusMonths(8), LocalDate.now().minusMonths(2), + false, null ); - // AUTHORITY - createUserIfNotExists( - "authority@dev.com", - defaultPassword, - "Jan", - "Władza", - UserRole.AUTHORITY + Project aiResearch = createProjectIfNotExists( + "Badania nad AI", + "Projekt badawczy dotyczący zastosowań AI w analizie obrazów medycznych.", + ext.projectManagerThree(), + LocalDate.now().minusMonths(1), LocalDate.now().plusMonths(11), + true, groups.research() + ); + Project graphResearch = createProjectIfNotExists( + "Analiza grafów społecznych", + "Badania nad strukturami społeczności w sieciach naukowych.", + ext.projectManagerThree(), + LocalDate.now().plusWeeks(2), LocalDate.now().plusMonths(9), + true, groups.research() ); - // COMMON - createUserIfNotExists( - "common@dev.com", - defaultPassword, - "Maciej", - "Pracownik", - UserRole.COMMON, - linearManager + Project lan = createProjectIfNotExists( + "Modernizacja sieci LAN", + "Wymiana infrastruktury sieciowej w budynku D17.", + base.authority(), + LocalDate.now().minusMonths(1), LocalDate.now().plusMonths(8), + true, groups.digitalization() + ); + Project securityAudit = createProjectIfNotExists( + "Audyt bezpieczeństwa IT", + "Kompleksowy audyt bezpieczeństwa systemów wydziałowych.", + base.authority(), + LocalDate.now().minusMonths(5), LocalDate.now().minusWeeks(2), + false, null ); - log.info("DEV users initialization completed."); + return new Projects(recruitment, alumni, mobileApp, elearning, libraryMigration, + aiResearch, graphResearch, lan, securityAudit); } - private User createUserIfNotExists(String email, String encodedPassword, String name, String surname, UserRole role) { - return createUserIfNotExists(email, encodedPassword, name, surname, role, null); + private void seedAssignments(BaseUsers base, ExtendedUsers ext, Projects p) { + createAssignmentIfNotExists(base.common(), p.recruitment(), "Backend Developer", + LocalDate.now().minusMonths(1), LocalDate.now().plusMonths(3), 60); + createAssignmentIfNotExists(base.common(), p.lan(), "Konsultant techniczny", + LocalDate.now().plusWeeks(2), LocalDate.now().plusMonths(2), 50); + + createAssignmentIfNotExists(ext.commonTwo(), p.mobileApp(), "Frontend Developer", + LocalDate.now().minusWeeks(2), LocalDate.now().plusMonths(5), 80); + + createAssignmentIfNotExists(ext.commonThree(), p.aiResearch(), "ML Engineer", + LocalDate.now().minusWeeks(3), LocalDate.now().plusMonths(8), 100); + + createAssignmentIfNotExists(ext.commonFour(), p.recruitment(), "UX Designer", + LocalDate.now().minusMonths(1), LocalDate.now().plusWeeks(6), 30); + createAssignmentIfNotExists(ext.commonFour(), p.mobileApp(), "UX Designer", + LocalDate.now().minusWeeks(1), LocalDate.now().plusMonths(4), 40); + + createAssignmentIfNotExists(ext.commonFive(), p.libraryMigration(), "DevOps Engineer", + LocalDate.now().minusMonths(3), LocalDate.now().minusWeeks(4), 70); + createAssignmentIfNotExists(ext.commonFive(), p.elearning(), "DevOps Engineer", + LocalDate.now().minusMonths(1), LocalDate.now().plusMonths(2), 50); + + createAssignmentIfNotExists(ext.commonSix(), p.elearning(), "QA Engineer", + LocalDate.now().minusWeeks(2), LocalDate.now().plusMonths(2), 60); + createAssignmentIfNotExists(ext.commonSix(), p.recruitment(), "QA Engineer", + LocalDate.now().plusWeeks(1), LocalDate.now().plusMonths(3), 40); + + createAssignmentIfNotExists(ext.commonSeven(), p.alumni(), "Backend Developer", + LocalDate.now().minusMonths(4), LocalDate.now().minusMonths(1), 100); } private User createUserIfNotExists(String email, String encodedPassword, String name, String surname, UserRole role, User supervisor) { - return userRepository.findByEmail(email).orElseGet(() -> { - User user = User.builder() - .email(email) - .password(encodedPassword) - .name(name) - .surname(surname) - .userRole(role) - .userStatus(UserStatus.ACTIVE) - .supervisor(supervisor) - .build(); + return userRepository.findByEmail(email) + .map(existing -> { + if (supervisor != null && existing.getSupervisor() == null) { + existing.setSupervisor(supervisor); + User updated = userRepository.save(existing); + log.info("Backfilled supervisor for {}: → {}", email, supervisor.getEmail()); + return updated; + } + return existing; + }) + .orElseGet(() -> { + User user = User.builder() + .email(email) + .password(encodedPassword) + .name(name) + .surname(surname) + .userRole(role) + .userStatus(UserStatus.ACTIVE) + .supervisor(supervisor) + .build(); + + User savedUser = userRepository.save(user); + log.info("Created user: {} with role: {}", email, role); + return savedUser; + }); + } + + private void ensureSupervisor(User user, User supervisor) { + if (supervisor == null || user.getSupervisor() != null) return; + user.setSupervisor(supervisor); + userRepository.save(user); + log.info("Backfilled supervisor for {}: → {}", user.getEmail(), supervisor.getEmail()); + } - User savedUser = userRepository.save(user); - log.info("Created user: {} with role: {}", email, role); - return savedUser; + private Skill getOrCreateSkill(String name, boolean valid) { + return skillRepository.findByNameIgnoreCase(name).orElseGet(() -> { + Skill skill = Skill.builder().name(name).valid(valid).build(); + return skillRepository.save(skill); }); } -} \ No newline at end of file + + private void addAcceptedQualifications(User user, List skillNames) { + addQualifications(user, skillNames, QualificationStatus.ACCEPTED); + } + + private void addWaitingQualifications(User user, List skillNames) { + addQualifications(user, skillNames, QualificationStatus.WAITING); + } + + private void addRejectedQualifications(User user, List skillNames) { + addQualifications(user, skillNames, QualificationStatus.REJECTED); + } + + private void addQualifications(User user, List skillNames, QualificationStatus status) { + boolean valid = status == QualificationStatus.ACCEPTED; + for (String skillName : skillNames) { + Skill skill = getOrCreateSkill(skillName, valid); + boolean exists = qualificationRepository.findAllByUser_Id(user.getId()).stream() + .anyMatch(q -> q.getSkill().getId().equals(skill.getId())); + if (exists) continue; + + Qualification q = Qualification.builder() + .user(user) + .skill(skill) + .status(status) + .build(); + qualificationRepository.save(q); + } + } + + private ProjectGroup createProjectGroupIfNotExists(String name, String description, User owner, GroupType type) { + return projectGroupRepository.findAll().stream() + .filter(g -> g.getName().equals(name)) + .findFirst() + .orElseGet(() -> { + ProjectGroup group = ProjectGroup.builder() + .name(name) + .description(description) + .owner(owner) + .groupType(type) + .build(); + ProjectGroup saved = projectGroupRepository.save(group); + log.info("Created project group: {} ({})", name, type); + return saved; + }); + } + + private Project createProjectIfNotExists(String title, String description, User manager, + LocalDate start, LocalDate end, boolean isActive, + ProjectGroup group) { + return projectRepository.findAllByProjectManagerId(manager.getId()).stream() + .filter(p -> p.getTitle().equals(title)) + .findFirst() + .orElseGet(() -> { + Project project = Project.builder() + .title(title) + .description(description) + .projectManager(manager) + .projectGroup(group) + .startDate(start) + .endDate(end) + .isActive(isActive) + .build(); + Project saved = projectRepository.save(project); + log.info("Created project: {} (manager: {})", title, manager.getEmail()); + return saved; + }); + } + + private void createAssignmentIfNotExists(User user, Project project, String roleName, + LocalDate start, LocalDate end, int utilization) { + boolean exists = assignmentRepository.findAllByUserIdAndStatus(user.getId(), AssignmentStatus.ACCEPTED).stream() + .anyMatch(a -> a.getProject().getId().equals(project.getId()) && a.getRoleName().equals(roleName)); + if (exists) return; + + ProjectAssignment assignment = ProjectAssignment.builder() + .user(user) + .project(project) + .roleName(roleName) + .startDate(start) + .endDate(end) + .utilizationPercentage(utilization) + .status(AssignmentStatus.ACCEPTED) + .build(); + assignmentRepository.save(assignment); + log.info("Created assignment: {} → {} ({}%)", user.getEmail(), project.getTitle(), utilization); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/advice/GlobalExceptionHandler.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/advice/GlobalExceptionHandler.java index e0ec4b71..91357cac 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/advice/GlobalExceptionHandler.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/advice/GlobalExceptionHandler.java @@ -5,11 +5,15 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; +import java.util.HashMap; +import java.util.stream.Collectors; + @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -63,4 +67,19 @@ public ResponseEntity handleGeneralException(Exception ex) { .status(error.getHttpStatus()) .body(body); } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + var error = ApiErrorCode.VALIDATION_FAILED; + + var body = new ApiErrorResponse(error.getCode(), message); + + return ResponseEntity + .status(error.getHttpStatus()) + .body(body); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java index 7204c89f..c1caa752 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java @@ -29,7 +29,7 @@ public class ApprovalsController { private final QualificationManagementService qualificationService; @GetMapping("/assignments/pending") - @PreAuthorize("hasRole('LINEAR_MANAGER')") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY')") public ResponseEntity> getPendingAssignments( @AuthenticationPrincipal UserPrincipal principal ) { @@ -38,7 +38,7 @@ public ResponseEntity> getPendingAssignments( } @PostMapping("/assignments/{assignmentId}/accept") - @PreAuthorize("hasRole('LINEAR_MANAGER') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") public ResponseEntity acceptAssignment( @PathVariable UUID assignmentId, @AuthenticationPrincipal UserPrincipal principal @@ -48,7 +48,7 @@ public ResponseEntity acceptAssignment( } @PostMapping("/assignments/{assignmentId}/reject") - @PreAuthorize("hasRole('LINEAR_MANAGER') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") public ResponseEntity rejectAssignment( @PathVariable UUID assignmentId, @AuthenticationPrincipal UserPrincipal principal @@ -58,7 +58,7 @@ public ResponseEntity rejectAssignment( } @GetMapping("/assignments/{assignmentId}/details") - @PreAuthorize("hasRole('LINEAR_MANAGER') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY') and @assignmentAccess.canManageAssignment(#assignmentId, authentication.principal)") public ResponseEntity getAssignmentDetails( @PathVariable UUID assignmentId, @AuthenticationPrincipal UserPrincipal principal @@ -68,19 +68,19 @@ public ResponseEntity getAssignmentDetails( } @GetMapping("/qualifications") - @PreAuthorize("hasRole('LINEAR_MANAGER')") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY')") public ResponseEntity> getUsersWithWaitingQualifications(@AuthenticationPrincipal UserPrincipal principal) { return ResponseEntity.ok(qualificationService.getRecordsForManager(principal.userId())); } @GetMapping("/qualifications/details") - @PreAuthorize("hasRole('LINEAR_MANAGER') and @qualificationSecurity.isManagerForUser(principal.userId(), #userId)") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY') and @qualificationSecurity.isManagerForUser(principal.userId(), #userId)") public ResponseEntity> getQualificationRequestDetails(@RequestParam UUID userId) { return ResponseEntity.ok(qualificationService.getPendingForUser(userId)); } @PostMapping("/qualifications/bulk-update") - @PreAuthorize("hasRole('LINEAR_MANAGER')") + @PreAuthorize("hasAnyRole('LINEAR_MANAGER', 'AUTHORITY')") public ResponseEntity updateQualificationRequests( @Valid @RequestBody List requests, @AuthenticationPrincipal UserPrincipal principal diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/ChartIntervalResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/common/ChartIntervalResponse.java similarity index 68% rename from backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/ChartIntervalResponse.java rename to backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/common/ChartIntervalResponse.java index 1db44f4d..becfd95f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/ChartIntervalResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/common/ChartIntervalResponse.java @@ -1,4 +1,4 @@ -package pl.edu.agh.project_manager.controller.dto.employee_requests; +package pl.edu.agh.project_manager.controller.dto.common; import java.time.LocalDate; diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentDetailsResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentDetailsResponse.java index 0bbaa097..7101ad5c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentDetailsResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentDetailsResponse.java @@ -1,5 +1,7 @@ package pl.edu.agh.project_manager.controller.dto.employee_requests; +import pl.edu.agh.project_manager.controller.dto.common.ChartIntervalResponse; + import java.util.List; public record EmployeeAssignmentDetailsResponse( diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentRequestStatus.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentRequestStatus.java deleted file mode 100644 index 76c27921..00000000 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentRequestStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package pl.edu.agh.project_manager.controller.dto.employee_requests; - -public enum EmployeeAssignmentRequestStatus { - PENDING, - ACCEPTED, - REJECTED -} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/milestone/MilestoneResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/milestone/MilestoneResponse.java index dd0b780a..4215bf8d 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/milestone/MilestoneResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/milestone/MilestoneResponse.java @@ -6,11 +6,8 @@ public record MilestoneResponse ( UUID id, - String name, - String description, - LocalDate date ) { public static MilestoneResponse from(ProjectMilestone milestone) { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentResponse.java index 5549ed80..a48f24cd 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentResponse.java @@ -10,24 +10,22 @@ public record AssignmentResponse( UUID id, UUID projectId, - UUID userId, + String projectName, String roleName, - LocalDate startDate, - LocalDate endDate, - Integer utilizationPercentage, - AssignmentStatus status, + String employeeName, + String employeeSurname, + AssignmentStatusResponse status, LocalDateTime createdAt ) { public static AssignmentResponse from(ProjectAssignment assignment) { return new AssignmentResponse( assignment.getId(), assignment.getProject().getId(), - assignment.getUser().getId(), + assignment.getProject().getTitle(), assignment.getRoleName(), - assignment.getStartDate(), - assignment.getEndDate(), - assignment.getUtilizationPercentage(), - assignment.getStatus(), + assignment.getUser().getName(), + assignment.getUser().getSurname(), + AssignmentStatusResponse.from(assignment.getStatus()), assignment.getCreatedAt() ); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentStatusResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentStatusResponse.java new file mode 100644 index 00000000..c19e28c2 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentStatusResponse.java @@ -0,0 +1,17 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.domain.enums.AssignmentStatus; + +public enum AssignmentStatusResponse { + PENDING, + ACCEPTED, + REJECTED; + + public static AssignmentStatusResponse from(AssignmentStatus status) { + return switch (status) { + case PENDING -> PENDING; + case ACCEPTED -> ACCEPTED; + case REJECTED -> REJECTED; + }; + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentsByEmployeeResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentsByEmployeeResponse.java new file mode 100644 index 00000000..180facc0 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentsByEmployeeResponse.java @@ -0,0 +1,27 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; +import pl.edu.agh.project_manager.domain.entity.user.User; + +import java.util.List; +import java.util.UUID; + +public record AssignmentsByEmployeeResponse( + UUID userId, + String name, + String surname, + String email, + List assignments +) { + public static AssignmentsByEmployeeResponse from(User user, List userAssignments) { + return new AssignmentsByEmployeeResponse( + user.getId(), + user.getName(), + user.getSurname(), + user.getEmail(), + userAssignments.stream() + .map(ProjectAssignmentResponse::from) + .toList() + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentResponse.java new file mode 100644 index 00000000..2a9723e4 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentResponse.java @@ -0,0 +1,26 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; + +import java.time.LocalDate; +import java.util.UUID; + +public record ProjectAssignmentResponse( + UUID id, + LocalDate startDate, + LocalDate endDate, + AssignmentStatusResponse status, + String roleName, + int utilizationPercentage +) { + public static ProjectAssignmentResponse from(ProjectAssignment assignment) { + return new ProjectAssignmentResponse( + assignment.getId(), + assignment.getStartDate(), + assignment.getEndDate(), + AssignmentStatusResponse.from(assignment.getStatus()), + assignment.getRoleName(), + assignment.getUtilizationPercentage() + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentUserWorkloadResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentUserWorkloadResponse.java new file mode 100644 index 00000000..67a52e91 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentUserWorkloadResponse.java @@ -0,0 +1,10 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.controller.dto.common.ChartIntervalResponse; + +import java.util.List; + +public record ProjectAssignmentUserWorkloadResponse( + List workload +) { +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectCreationRequest.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectCreationRequest.java index 7497fe7e..883a5024 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectCreationRequest.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectCreationRequest.java @@ -2,9 +2,9 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import pl.edu.agh.project_manager.controller.dto.milestone.MilestoneRequest; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskRequest; import pl.edu.agh.project_manager.service.command.project.ProjectCreationCommand; import java.time.LocalDate; @@ -26,20 +26,20 @@ public record ProjectCreationRequest( UUID projectGroupId, - @NotEmpty(message = "Lista sponsorów nie może być pusta") List sponsors, - @NotEmpty(message = "Lista członków komitetu sterującego nie może być pusta") List committee, @Valid - List risks, + List risks, @Valid List milestones ) { public ProjectCreationRequest { if (risks == null || risks.isEmpty()) risks = List.of(); + if (sponsors == null) sponsors = List.of(); + if (committee == null) committee = List.of(); } public ProjectCreationCommand toCommand(UUID creatorId) { @@ -50,7 +50,7 @@ public ProjectCreationCommand toCommand(UUID creatorId) { this.startDate, this.endDate, this.projectGroupId, - this.risks.stream().map(RiskRequest::toCommand).toList(), + this.risks.stream().map(ProjectRiskRequest::toCommand).toList(), this.milestones.stream().map(MilestoneRequest::toCommand).toList(), this.sponsors, this.committee diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectResponse.java index e58a2892..8719bfb0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectResponse.java @@ -2,6 +2,7 @@ import pl.edu.agh.project_manager.controller.dto.user.UserResponse; import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.controller.dto.project_group.GroupBasicResponse; import java.time.LocalDate; import java.util.UUID; @@ -13,7 +14,8 @@ public record ProjectResponse( LocalDate startDate, LocalDate endDate, Boolean isActive, - UserResponse manager + UserResponse manager, + GroupBasicResponse group ) { public static ProjectResponse from(Project project) { return new ProjectResponse( @@ -23,7 +25,8 @@ public static ProjectResponse from(Project project) { project.getStartDate(), project.getEndDate(), project.getIsActive(), - UserResponse.from(project.getProjectManager()) + UserResponse.from(project.getProjectManager()), + GroupBasicResponse.from(project.getProjectGroup()) ); } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectTimelineResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectTimelineResponse.java new file mode 100644 index 00000000..5a3358e3 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectTimelineResponse.java @@ -0,0 +1,11 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.controller.dto.milestone.MilestoneResponse; + +import java.util.List; + +public record ProjectTimelineResponse( + List milestones, + List assignments +) { +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskResponse.java deleted file mode 100644 index 391f6beb..00000000 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package pl.edu.agh.project_manager.controller.dto.project; - -import pl.edu.agh.project_manager.domain.entity.project.ProjectRisk; - -import java.util.UUID; - -public record RiskResponse( - UUID id, - String name, - String description, - Integer probability -) { - public static RiskResponse from(ProjectRisk risk) { - return new RiskResponse( - risk.getId(), - risk.getName(), - risk.getDescription(), - risk.getProbability() - ); - } -} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/UserProjectMembershipResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/UserProjectMembershipResponse.java new file mode 100644 index 00000000..c4222500 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/UserProjectMembershipResponse.java @@ -0,0 +1,49 @@ +package pl.edu.agh.project_manager.controller.dto.project; + +import pl.edu.agh.project_manager.controller.dto.project_group.GroupBasicResponse; +import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record UserProjectMembershipResponse( + UUID id, + String title, + String description, + LocalDate startDate, + LocalDate endDate, + Boolean isActive, + GroupBasicResponse group, + List roles +) { + public static UserProjectMembershipResponse from(Project project, List userAssignments) { + return new UserProjectMembershipResponse( + project.getId(), + project.getTitle(), + project.getDescription(), + project.getStartDate(), + project.getEndDate(), + project.getIsActive(), + GroupBasicResponse.from(project.getProjectGroup()), + userAssignments.stream().map(UserProjectRoleResponse::from).toList() + ); + } + + public record UserProjectRoleResponse( + String roleName, + LocalDate startDate, + LocalDate endDate, + Integer utilizationPercentage + ) { + public static UserProjectRoleResponse from(ProjectAssignment assignment) { + return new UserProjectRoleResponse( + assignment.getRoleName(), + assignment.getStartDate(), + assignment.getEndDate(), + assignment.getUtilizationPercentage() + ); + } + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/AllGroupsResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/AllGroupsResponse.java index 8cb50d67..af980c92 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/AllGroupsResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/AllGroupsResponse.java @@ -1,9 +1,11 @@ package pl.edu.agh.project_manager.controller.dto.project_group; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; import java.util.List; public record AllGroupsResponse( - List wallets, - List programs + List wallets, + List programs, + List unassigned ) { } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/GroupBasicResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/GroupBasicResponse.java new file mode 100644 index 00000000..29a9afbd --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/GroupBasicResponse.java @@ -0,0 +1,23 @@ +package pl.edu.agh.project_manager.controller.dto.project_group; + +import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; +import pl.edu.agh.project_manager.domain.enums.GroupType; + +import java.util.UUID; + +public record GroupBasicResponse( + UUID id, + String name, + GroupType groupType +) { + public static GroupBasicResponse from(ProjectGroup group) { + if (group == null) { + return null; + } + return new GroupBasicResponse( + group.getId(), + group.getName(), + group.getGroupType() + ); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/OwnedGroupResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/OwnedGroupResponse.java new file mode 100644 index 00000000..88ea1353 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/OwnedGroupResponse.java @@ -0,0 +1,32 @@ +package pl.edu.agh.project_manager.controller.dto.project_group; + +import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; +import pl.edu.agh.project_manager.domain.enums.GroupType; + +import java.util.UUID; + +public record OwnedGroupResponse( + UUID id, + String name, + String description, + GroupType groupType, + int projectCount, + int activeProjectCount, + boolean isOwner +) { + public static OwnedGroupResponse from(ProjectGroup group, boolean isOwner) { + int total = group.getProjects() != null ? group.getProjects().size() : 0; + int active = group.getProjects() != null + ? (int) group.getProjects().stream().filter(p -> Boolean.TRUE.equals(p.getIsActive())).count() + : 0; + return new OwnedGroupResponse( + group.getId(), + group.getName(), + group.getDescription(), + group.getGroupType(), + total, + active, + isOwner + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupResponse.java new file mode 100644 index 00000000..f5f4922e --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/ProjectGroupResponse.java @@ -0,0 +1,22 @@ +package pl.edu.agh.project_manager.controller.dto.project_group; + +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.domain.entity.projectgroup.ProjectGroup; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public record ProjectGroupResponse( + UUID id, + String name, + List projects +) { + public static ProjectGroupResponse from(ProjectGroup projectGroup) { + return new ProjectGroupResponse( + projectGroup.getId(), + projectGroup.getName(), + projectGroup.getProjects().stream().map(ProjectResponse::from).collect(Collectors.toList()) + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/SingleGroupResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/SingleGroupResponse.java deleted file mode 100644 index 0a778723..00000000 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_group/SingleGroupResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package pl.edu.agh.project_manager.controller.dto.project_group; - -import java.util.UUID; - -public record SingleGroupResponse ( - UUID id, - String name -) { -} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskRequest.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskRequest.java similarity index 58% rename from backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskRequest.java rename to backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskRequest.java index 7450f52e..89d591d3 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/RiskRequest.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskRequest.java @@ -1,11 +1,11 @@ -package pl.edu.agh.project_manager.controller.dto.project; +package pl.edu.agh.project_manager.controller.dto.project_risk; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import org.hibernate.validator.constraints.Range; import pl.edu.agh.project_manager.service.command.project.RiskCommand; -public record RiskRequest( +public record ProjectRiskRequest( @NotBlank(message = "Nazwa ryzyka nie może być pusta!") String name, @@ -13,14 +13,19 @@ public record RiskRequest( String description, @NotNull(message = "Poziom prawdopodobieństwa jest wymagany!") - @Range(min = 0, max = 100, message = "Prawdopodobieństwo musi być w skali 0-100%!") - Integer probability + @Range(min = 1, max = 5, message = "Prawdopodobieństwo musi być w skali 1-5!") + Integer probability, + + @NotNull(message = "Poziom wpływu jest wymagany!") + @Range(min = 1, max = 5, message = "Wpływ musi być w skali 1-5!") + Integer impact ) { public RiskCommand toCommand() { return new RiskCommand( this.name, this.description, - this.probability + this.probability, + this.impact ); } -} +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskResponse.java new file mode 100644 index 00000000..a823c7a6 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project_risk/ProjectRiskResponse.java @@ -0,0 +1,25 @@ +package pl.edu.agh.project_manager.controller.dto.project_risk; + +import pl.edu.agh.project_manager.domain.entity.project.ProjectRisk; + +import java.util.UUID; + +public record ProjectRiskResponse( + UUID id, + String name, + String description, + Integer probability, + Integer impact, + Integer value +) { + public static ProjectRiskResponse from(ProjectRisk risk) { + return new ProjectRiskResponse( + risk.getId(), + risk.getName(), + risk.getDescription(), + risk.getProbability(), + risk.getImpact(), + risk.getProbability() * risk.getImpact() + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/SimpleUserResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/SimpleUserResponse.java index b7404006..319a35e0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/SimpleUserResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/SimpleUserResponse.java @@ -7,14 +7,16 @@ public record SimpleUserResponse( UUID id, String name, - String surname + String surname, + String email ) { public static SimpleUserResponse fromUser(User user) { return new SimpleUserResponse( user.getId(), user.getName(), - user.getSurname() + user.getSurname(), + user.getEmail() ); } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/UserResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/UserResponse.java index b6c500f9..eb6bae00 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/UserResponse.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/user/UserResponse.java @@ -16,6 +16,7 @@ public record UserResponse( UserRole role, UserStatus status, String supervisorEmail, + SimpleUserResponse supervisor, List qualifications ) { public static UserResponse from(User user) { @@ -27,6 +28,7 @@ public static UserResponse from(User user) { user.getUserRole(), user.getUserStatus(), user.getSupervisor() != null ? user.getSupervisor().getEmail() : null, + user.getSupervisor() != null ? SimpleUserResponse.fromUser(user.getSupervisor()) : null, user.getQualifications() != null ? user.getQualifications().stream() diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java index b96f8ae4..7e075e9e 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java @@ -15,6 +15,7 @@ import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.notification.NotificationService; +import java.util.Map; import java.util.UUID; @RestController @@ -31,6 +32,12 @@ public ResponseEntity streamNotifications(@AuthenticationPrincipal U return ResponseEntity.ok(emitter); } + @GetMapping("/unread-count") + public ResponseEntity> getUnreadCount(@AuthenticationPrincipal UserPrincipal userPrincipal) { + int count = notificationService.getUnreadCount(userPrincipal.userId()); + return ResponseEntity.ok(Map.of("count", count)); + } + @GetMapping public ResponseEntity> getUserNotifications( @RequestParam(defaultValue = "true") boolean unreadOnly, diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectAssignmentController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectAssignmentController.java index 4b15d7b2..4dadbfa9 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectAssignmentController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectAssignmentController.java @@ -31,7 +31,7 @@ public ResponseEntity createAssignment( } @GetMapping - @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") public ResponseEntity> getAssignments( @PathVariable UUID projectId ) { 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 b45618e6..1198774e 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,6 @@ 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; @@ -24,7 +23,7 @@ public class ProjectController { private final ProjectService projectService; @PostMapping - @PreAuthorize("hasRole('PROJECT_MANAGER')") + @PreAuthorize("hasAnyRole('PROJECT_MANAGER', 'AUTHORITY')") public ResponseEntity createProject( @Valid @RequestBody ProjectCreationRequest projectCreationRequest, @AuthenticationPrincipal UserPrincipal userPrincipal @@ -41,18 +40,20 @@ public ResponseEntity> getAllProjects( @AuthenticationPrincipal UserPrincipal userPrincipal, @RequestParam(value = "query", required = false) String query, @RequestParam(value = "groupId", required = false) UUID groupId, + @RequestParam(value = "isActive", required = false) Boolean isActive, @RequestParam(value = "unassignedOnly", required = false, defaultValue = "false") Boolean unassignedOnly ) { SearchProjectCommand command = new SearchProjectCommand( userPrincipal, query, groupId, + isActive, unassignedOnly ); return ResponseEntity.ok(projectService.searchProjects(command)); } - @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") @GetMapping("/{projectId}") public ResponseEntity getProject( @PathVariable UUID projectId @@ -61,7 +62,7 @@ public ResponseEntity getProject( return ResponseEntity.ok(project); } - @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") @GetMapping("/{projectId}/members") public ResponseEntity getProjectMembers( @PathVariable UUID projectId @@ -69,6 +70,15 @@ public ResponseEntity getProjectMembers( ProjectMembersResponse project = projectService.getProjectMembers(projectId); return ResponseEntity.ok(project); } + + @GetMapping("/{projectId}/timeline") + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + public ResponseEntity getTimelineData( + @PathVariable UUID projectId + ) { + ProjectTimelineResponse response = projectService.getTimelineData(projectId); + return ResponseEntity.ok(response); + } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectMilestoneController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectMilestoneController.java index 950e6a36..b97b0f79 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectMilestoneController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectMilestoneController.java @@ -22,7 +22,7 @@ public class ProjectMilestoneController { private final ProjectMilestoneService milestoneService; @GetMapping - @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") public ResponseEntity> getMilestones( @PathVariable UUID projectId ) { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectRiskController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectRiskController.java index c86c6bfb..dfa2d65b 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectRiskController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/project/ProjectRiskController.java @@ -6,8 +6,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import pl.edu.agh.project_manager.controller.dto.project.RiskRequest; -import pl.edu.agh.project_manager.controller.dto.project.RiskResponse; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskRequest; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; import pl.edu.agh.project_manager.service.project.ProjectRiskService; import java.util.List; @@ -21,11 +21,11 @@ public class ProjectRiskController { @PostMapping @PreAuthorize("@projectAccess.isProjectManagerForProject(#projectId, authentication.principal)") - public ResponseEntity createProjectRisk( + public ResponseEntity createProjectRisk( @PathVariable UUID projectId, - @Valid @RequestBody RiskRequest riskRequest + @Valid @RequestBody ProjectRiskRequest riskRequest ) { - RiskResponse createdRisk = riskService.createProjectRisk( + ProjectRiskResponse createdRisk = riskService.createProjectRisk( riskRequest.toCommand(), projectId ); @@ -33,22 +33,22 @@ public ResponseEntity createProjectRisk( } @GetMapping - @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") - public ResponseEntity> getRisks( + @PreAuthorize("hasAnyRole('ADMINISTRATOR', 'AUTHORITY') or @projectAccess.canAccessProject(#projectId, authentication.principal)") + public ResponseEntity> getRisks( @PathVariable UUID projectId ) { - List risks = riskService.getProjectRisks(projectId); + List risks = riskService.getProjectRisks(projectId); return ResponseEntity.ok(risks); } @PatchMapping("/{riskId}") @PreAuthorize("@projectAccess.isProjectManagerForProject(#projectId, authentication.principal)") - public ResponseEntity updateProjectRisk( + public ResponseEntity updateProjectRisk( @PathVariable UUID projectId, @PathVariable UUID riskId, - @Valid @RequestBody RiskRequest riskRequest + @Valid @RequestBody ProjectRiskRequest riskRequest ) { - RiskResponse updatedRisk = riskService.updateProjectRisk( + ProjectRiskResponse updatedRisk = riskService.updateProjectRisk( projectId, riskId, riskRequest.toCommand() diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/projectgroup/ProjectGroupController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/projectgroup/ProjectGroupController.java index ae9f5eda..7f0cc000 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/projectgroup/ProjectGroupController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/projectgroup/ProjectGroupController.java @@ -9,7 +9,7 @@ import pl.edu.agh.project_manager.controller.dto.project_group.AllGroupsResponse; import pl.edu.agh.project_manager.controller.dto.project_group.ProjectGroupCreationRequest; 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.controller.dto.project_group.GroupBasicResponse; import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.projectgroup.ProjectGroupsService; import pl.edu.agh.project_manager.service.command.project.ProjectGroupCreationCommand; @@ -26,17 +26,17 @@ public class ProjectGroupController { private final ProjectGroupsService projectGroupsService; @GetMapping("/groups") - public ResponseEntity getAllGroups() { - return ResponseEntity.ok().body(projectGroupsService.getAllGroups()); + public ResponseEntity getAllGroups(@AuthenticationPrincipal UserPrincipal userPrincipal) { + return ResponseEntity.ok().body(projectGroupsService.getAllGroups(userPrincipal)); } @GetMapping("/groups/wallets") - public ResponseEntity> getAllWallets() { + public ResponseEntity> getAllWallets() { return ResponseEntity.ok().body(projectGroupsService.getWalletGroups()); } @GetMapping("/groups/programs") - public ResponseEntity> getAllPrograms() { + public ResponseEntity> getAllPrograms() { return ResponseEntity.ok().body(projectGroupsService.getProgramGroups()); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportController.java new file mode 100644 index 00000000..da341319 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportController.java @@ -0,0 +1,51 @@ +package pl.edu.agh.project_manager.controller.report; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import pl.edu.agh.project_manager.security.UserPrincipal; +import pl.edu.agh.project_manager.service.report.ReportService; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @PreAuthorize("hasAnyAuthority('ADMINISTRATOR', 'AUTHORITY') or " + + "(#type.name().startsWith('PROJECT_') and @projectAccess.canAccessProject(#resourceId, principal)) or " + + "(#type.name().startsWith('GROUP_') and @groupAccess.canAccessGroup(#resourceId, principal))") + @GetMapping("/{resourceId}") + public ResponseEntity downloadReport( + @PathVariable UUID resourceId, + @RequestParam ReportType type, + @AuthenticationPrincipal UserPrincipal user + ) { + byte[] reportData = switch (type) { + case PROJECT_CARD_PDF -> reportService.generateProjectCardPdf(resourceId, user); + case PROJECT_RISKS_PDF -> reportService.generateProjectRisksPdf(resourceId, user); + case PROJECT_RISKS_CSV -> reportService.generateProjectRisksCsv(resourceId, user); + case GROUP_PROJECTS_CSV -> reportService.generatePortfolioCsv(resourceId, user); + }; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(type.getContentType())); + + ContentDisposition disposition = ContentDisposition.attachment() + .filename(type.getFilename()) + .build(); + headers.setContentDisposition(disposition); + + return ResponseEntity.ok() + .headers(headers) + .body(reportData); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportType.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportType.java new file mode 100644 index 00000000..89852c4f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/report/ReportType.java @@ -0,0 +1,24 @@ +package pl.edu.agh.project_manager.controller.report; + +public enum ReportType { + PROJECT_CARD_PDF("karta_projektu.pdf", "application/pdf"), + PROJECT_RISKS_PDF("rejestr_ryzyk.pdf", "application/pdf"), + PROJECT_RISKS_CSV("rejestr_ryzyk.csv", "text/csv"), + GROUP_PROJECTS_CSV("lista_projektow_grupy.csv", "text/csv"); + + private final String filename; + private final String contentType; + + ReportType(String filename, String contentType) { + this.filename = filename; + this.contentType = contentType; + } + + public String getFilename() { + return filename; + } + + public String getContentType() { + return contentType; + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/ProfileController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/ProfileController.java index 77d1e3a2..864c7daa 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/ProfileController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/ProfileController.java @@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import pl.edu.agh.project_manager.controller.dto.auth.ChangePasswordRequest; +import pl.edu.agh.project_manager.controller.dto.user.UserResponse; import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.user.UserService; @@ -17,6 +18,12 @@ public class ProfileController { private final UserService userService; + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity getMyProfile(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(userService.getUser(principal.userId())); + } + @PostMapping("/password") @PreAuthorize("isAuthenticated()") public ResponseEntity changePassword( diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/UserController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/UserController.java index 29d2675b..b12b8a43 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/UserController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/UserController.java @@ -9,10 +9,17 @@ import pl.edu.agh.project_manager.controller.dto.PagedResponse; import pl.edu.agh.project_manager.controller.dto.invitation.AdminUserInvitationRequest; import pl.edu.agh.project_manager.controller.dto.invitation.ResendInvitationRequest; +import pl.edu.agh.project_manager.controller.dto.project.ProjectAssignmentUserWorkloadResponse; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.controller.dto.project.UserProjectMembershipResponse; +import pl.edu.agh.project_manager.controller.dto.project_group.OwnedGroupResponse; +import pl.edu.agh.project_manager.controller.dto.qualification.QualificationResponse; import pl.edu.agh.project_manager.controller.dto.user.SimpleUserResponse; import pl.edu.agh.project_manager.controller.dto.user.UserResponse; import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.service.approval.AssignmentManagementService; +import pl.edu.agh.project_manager.service.user.QualificationService; import pl.edu.agh.project_manager.service.user.UserInvitationService; import pl.edu.agh.project_manager.service.user.UserService; import pl.edu.agh.project_manager.service.command.invitation.AdminInviteUserCommand; @@ -27,6 +34,8 @@ class UserController { private final UserInvitationService invitationService; private final UserService userService; + private final AssignmentManagementService assignmentManagementService; + private final QualificationService qualificationService; @GetMapping("/users") @PreAuthorize("isAuthenticated()") @@ -87,4 +96,45 @@ public ResponseEntity> searchUsers( } return ResponseEntity.ok(userService.searchUsers(search)); } + + @GetMapping("/users/{userId}/workload") + public ResponseEntity getUserWorkload(@PathVariable UUID userId) { + return ResponseEntity.ok(assignmentManagementService.getUserWorkload(userId)); + } + + @GetMapping("/users/{userId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getUser(@PathVariable UUID userId) { + return ResponseEntity.ok(userService.getUser(userId)); + } + + @GetMapping("/users/{userId}/qualifications") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getUserQualifications(@PathVariable UUID userId) { + return ResponseEntity.ok(qualificationService.getUserQualifications(userId)); + } + + @GetMapping("/users/{userId}/subordinates") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getSubordinates(@PathVariable UUID userId) { + return ResponseEntity.ok(userService.getSubordinates(userId)); + } + + @GetMapping("/users/{userId}/projects") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getManagedProjects(@PathVariable UUID userId) { + return ResponseEntity.ok(userService.getManagedProjects(userId)); + } + + @GetMapping("/users/{userId}/memberships") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getProjectMemberships(@PathVariable UUID userId) { + return ResponseEntity.ok(userService.getProjectMemberships(userId)); + } + + @GetMapping("/users/{userId}/groups") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getRelatedGroups(@PathVariable UUID userId) { + return ResponseEntity.ok(userService.getRelatedProjectGroups(userId)); + } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/project/ProjectRisk.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/project/ProjectRisk.java index 78059b6e..27aa14ec 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/project/ProjectRisk.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/project/ProjectRisk.java @@ -26,13 +26,17 @@ public class ProjectRisk { @Column(name = "description", nullable = false, length = 500) private String description; - // TODO: Przejscie na model od 1 do 5 w skali prawdopodobienstwa wystapienia i skali ryzyka dla projektu - @Column(name = "probability", nullable = false, columnDefinition = "integer check (probability >= 0 and probability <= 100)") - @Min(value = 0, message = "Prawdopodobieństwo musi być większe bądź równe 0") - @Max(value = 100, message = "Prawdopodobieństwo musi być mniejsze bądź równe 100") + @Column(name = "probability", nullable = false, columnDefinition = "integer check (probability >= 1 and probability <= 5)") + @Min(value = 1, message = "Prawdopodobieństwo musi być w skali od 1 do 5") + @Max(value = 5, message = "Prawdopodobieństwo musi być w skali od 1 do 5") private Integer probability; + @Column(name = "impact", nullable = false, columnDefinition = "integer check (impact >= 1 and impact <= 5)") + @Min(value = 1, message = "Wpływ musi być w skali od 1 do 5") + @Max(value = 5, message = "Wpływ musi być w skali od 1 do 5") + private Integer impact; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "project_id", nullable = false) private Project project; -} +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java index 741850df..480413b0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java @@ -7,8 +7,9 @@ public record AssignmentAcceptedEvent( UUID assignmentId, - User recipient, String - message + User recipient, + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,12 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_ACCEPTED; } + + @Override + public String buildMessage() { + if (employeeFullName != null) { + return "Zaakceptowano przypisanie pracownika " + employeeFullName + " do projektu " + projectName; + } + return "Zostałeś dodany do projektu: " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java index 97eccd2b..100e31fc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRejectedEvent( UUID assignmentId, User recipient, - String message + String employeeName, + String projectName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono przypisanie pracownika " + employeeName + " do projektu " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java index 0ecfd997..2bda749c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRequestedEvent( UUID assignmentId, User recipient, - String message + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REQUESTED; } + + @Override + public String buildMessage() { + return "Kierownik projektu " + projectName + " prosi o alokację pracownika " + employeeFullName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java index 958ef54d..48bbd453 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java @@ -7,7 +7,7 @@ public interface NotificationEvent { User recipient(); - String message(); UUID referenceId(); NotificationType type(); + String buildMessage(); } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java index ddb54a78..8088eb13 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationAcceptedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_ACCEPTED; } + + @Override + public String buildMessage() { + return "Zatwierdzono Twoje kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java index 5b5ee14e..da0df94f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRejectedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono wnioski o kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java index e0530085..ba585196 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java @@ -3,12 +3,14 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRequestedEvent( UUID qualificationId, User recipient, - String message + String employeeFullName, + List skills ) implements NotificationEvent { @Override @@ -20,4 +22,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REQUESTED; } + + @Override + public String buildMessage() { + return employeeFullName + " zgłasza nowe umiejętności: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java index 3bc8f816..add2cc0c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java @@ -8,16 +8,17 @@ public record SystemNewEmployeeEvent( UUID employeeId, User recipient, - String message + String employeeFullName ) implements NotificationEvent { @Override - public UUID referenceId() { - return employeeId; - } + public UUID referenceId() { return employeeId; } + + @Override + public NotificationType type() { return NotificationType.SYSTEM_NEW_EMPLOYEE; } @Override - public NotificationType type() { - return NotificationType.SYSTEM_NEW_EMPLOYEE; + public String buildMessage() { + return String.format("Nowy pracownik %s aktywował swoje konto i dołączył do Twojego zespołu.", employeeFullName); } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java index 17e6bcd6..e77fbe7f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java @@ -22,12 +22,14 @@ public enum ApiErrorCode { INVALID_ROLE_UTILIZATION("PROJ_005", HttpStatus.BAD_REQUEST, "Role utilization percentages must match timeline segments length"), PROJECT_ROLE_NOT_FOUND("PROJ_006", HttpStatus.NOT_FOUND, "Cannot find provided project role"), PROJECT_NOT_FOUND("PROJ_007", HttpStatus.NOT_FOUND, "Cannot find provided project"), + ASSIGNMENT_INVALID_DATES("PROJ_008", HttpStatus.BAD_REQUEST, "Invalid dates for assignment"), ACTIVATION_TOKEN_NOT_FOUND("AUTH_001", HttpStatus.NOT_FOUND, "Activation token is invalid or does not exist"), ACTIVATION_TOKEN_EXPIRED("AUTH_002", HttpStatus.BAD_REQUEST, "Activation token has expired"), BAD_CREDENTIALS("AUTH_003", HttpStatus.UNAUTHORIZED, "Invalid email or password"), MISSING_REFRESH_TOKEN("AUTH_004", HttpStatus.UNAUTHORIZED, "Refresh token is missing or cookie expired"), INVALID_REFRESH_TOKEN("AUTH_005", HttpStatus.UNAUTHORIZED, "Refresh token is invalid"), + INVALID_SUPERVISOR_ROLE("AUTH_006", HttpStatus.BAD_REQUEST, "Selected user cannot be a supervisor"), USER_NOT_PENDING("USR_002", HttpStatus.BAD_REQUEST, "Cannot resend invitation — user is not in PENDING status"), QUALIFICATION_NOT_FOUND("QUAL_001", HttpStatus.NOT_FOUND, "Qualification not found"), @@ -45,8 +47,10 @@ public enum ApiErrorCode { INVALID_CURRENT_PASSWORD("USR_003", HttpStatus.UNAUTHORIZED, "Current password is incorrect"), PASSWORD_SAME_AS_CURRENT("USR_004", HttpStatus.BAD_REQUEST, "New password must be different from current password"), - VALIDATION_ERROR("GEN_001", HttpStatus.BAD_REQUEST, "Validation failed"), + REPORT_GENERATION_ERROR("REP_001", HttpStatus.INTERNAL_SERVER_ERROR, "Wystąpił błąd podczas generowania raportu"), + ACCESS_DENIED("GEN_002", HttpStatus.FORBIDDEN, "Access denied"), + VALIDATION_FAILED("GEN_998", HttpStatus.BAD_REQUEST, "Validation failed"), INTERNAL_SERVER_ERROR("GEN_999", HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected internal server error"); private final String code; diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java index e3cf3ed8..71b13e97 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java @@ -20,6 +20,8 @@ public interface NotificationRepository extends JpaRepository findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + Integer countAllByRecipientIdAndIsReadFalse(UUID recipientId); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :userId AND n.isRead = false") void markAllAsReadByUserId(@Param("userId") UUID userId); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectAssignmentRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectAssignmentRepository.java index 675085cd..74eba985 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectAssignmentRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/project/ProjectAssignmentRepository.java @@ -21,4 +21,11 @@ public interface ProjectAssignmentRepository extends JpaRepository findActiveAndPendingByProjectIdOrderByCreatedAtAsc(@Param("projectId") UUID projectId); } 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 d4f169f8..c94d8180 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 @@ -43,6 +43,9 @@ public interface ProjectRepository extends JpaRepository { @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"}) + @EntityGraph(attributePaths = {"projectManager", "projectGroup"}) List findAll(Specification spec); + + @EntityGraph(attributePaths = {"milestones"}) + Optional findWithMilestonesById(UUID id); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/projectgroup/ProjectGroupRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/projectgroup/ProjectGroupRepository.java index 9e4e8127..42724d3b 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/projectgroup/ProjectGroupRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/projectgroup/ProjectGroupRepository.java @@ -10,4 +10,6 @@ public interface ProjectGroupRepository extends JpaRepository { List getSingleGroupByGroupType(GroupType groupType); + + List findAllByOwner_IdOrderByNameAsc(UUID ownerId); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/UserRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/UserRepository.java index 9e1e9afe..43060e20 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/UserRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/UserRepository.java @@ -30,4 +30,6 @@ public interface UserRepository extends JpaRepository, JpaSpecificat @Query("SELECT u FROM User u WHERE LOWER(CONCAT(u.name, ' ', u.surname)) LIKE LOWER(CONCAT('%', :query, '%')) AND u.userRole = :role") List searchUserByFullNameAndRole(@Param("query") String query, @Param("role") UserRole role); + + List findAllBySupervisor_Id(UUID supervisorId); } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/GroupAccess.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/GroupAccess.java new file mode 100644 index 00000000..28bcc162 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/GroupAccess.java @@ -0,0 +1,33 @@ +package pl.edu.agh.project_manager.security.access; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import pl.edu.agh.project_manager.repository.projectgroup.ProjectGroupRepository; +import pl.edu.agh.project_manager.security.UserPrincipal; + +import java.util.UUID; + +@Component("groupAccess") +@RequiredArgsConstructor +public class GroupAccess { + + private final ProjectGroupRepository projectGroupRepository; + private final ProjectAccess projectAccess; + + public boolean canAccessGroup(UUID groupId, UserPrincipal currentUser) { + if (currentUser == null) { + return false; + } + + return projectGroupRepository.findById(groupId) + .map(group -> { + if (group.getOwner().getId().equals(currentUser.userId())) { + return true; + } + + return group.getProjects().stream() + .anyMatch(project -> projectAccess.canAccessProject(project.getId(), currentUser)); + }) + .orElse(false); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementService.java index a3548e3c..204bd831 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementService.java @@ -3,16 +3,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import pl.edu.agh.project_manager.controller.dto.employee_requests.ChartIntervalResponse; import pl.edu.agh.project_manager.controller.dto.employee_requests.EmployeeAssignmentDetailsResponse; +import pl.edu.agh.project_manager.controller.dto.project.ProjectAssignmentUserWorkloadResponse; import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; import pl.edu.agh.project_manager.service.project.ProjectAssignmentService; +import pl.edu.agh.project_manager.util.assignments.AssignmentsUtil; + -import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import java.util.UUID; -import java.util.stream.Stream; + @Service @RequiredArgsConstructor @@ -29,57 +29,16 @@ public EmployeeAssignmentDetailsResponse getEmployeeRequestDetails(UUID assignme List currentAssignments = projectAssignmentService.getAcceptedAssignmentsForUser(userId); return new EmployeeAssignmentDetailsResponse( - generateWorkloadSteps(currentAssignments), - generateWorkloadSteps(List.of(requestedAssignment)) + AssignmentsUtil.generateWorkloadSteps(currentAssignments), + AssignmentsUtil.generateWorkloadSteps(List.of(requestedAssignment)) ); } - private List generateWorkloadSteps(List assignments) { - if (assignments.isEmpty()) return List.of(); - - List timeline = assignments.stream() - .flatMap(a -> Stream.of(a.getStartDate(), a.getEndDate())) - .distinct() - .sorted() - .toList(); - - List steps = new ArrayList<>(); - - for (int i = 0; i < timeline.size() - 1; i++) { - LocalDate start = timeline.get(i); - LocalDate end = timeline.get(i + 1); - - int totalPercent = assignments.stream() - .filter(a -> !a.getStartDate().isAfter(start) && !a.getEndDate().isBefore(end)) - .mapToInt(ProjectAssignment::getUtilizationPercentage) - .sum(); - - if (totalPercent > 0) { - steps.add(new ChartIntervalResponse(start, end, totalPercent)); - } - } - - return mergeContinuousSteps(steps); - } - - private List mergeContinuousSteps(List steps) { - if (steps.size() < 2) return steps; - - List merged = new ArrayList<>(); - ChartIntervalResponse current = steps.getFirst(); - - for (int i = 1; i < steps.size(); i++) { - ChartIntervalResponse next = steps.get(i); - - if (current.percentage() == next.percentage() && current.endDate().equals(next.startDate())) { - current = new ChartIntervalResponse(current.startDate(), next.endDate(), current.percentage()); - } else { - merged.add(current); - current = next; - } - } - merged.add(current); - - return merged; + @Transactional(readOnly = true) + public ProjectAssignmentUserWorkloadResponse getUserWorkload(UUID userId) { + List currentAssignments = projectAssignmentService.getAcceptedAssignmentsForUser(userId); + return new ProjectAssignmentUserWorkloadResponse( + AssignmentsUtil.generateWorkloadSteps(currentAssignments) + ); } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java index 1442b2ab..edc9cf9f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.approval; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.approvals.QualificationDetailsResponse; @@ -11,13 +12,16 @@ import pl.edu.agh.project_manager.domain.entity.user.Skill; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.QualificationStatus; +import pl.edu.agh.project_manager.domain.event.NotificationEvent; +import pl.edu.agh.project_manager.domain.event.QualificationAcceptedEvent; +import pl.edu.agh.project_manager.domain.event.QualificationRejectedEvent; 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.user.QualificationRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; +import pl.edu.agh.project_manager.service.notification.NotificationSender; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Service @@ -25,6 +29,7 @@ public class QualificationManagementService { private final QualificationRepository qualificationRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public List getRecordsForManager(UUID managerId) { @@ -62,16 +67,43 @@ public void updateRequests(UUID managerId, List requ throw new ApplicationException(ApiErrorCode.QUALIFICATION_OWNER_NOT_SUBORDINATE); } + Map> acceptedSkillsByUser = new HashMap<>(); + Map> rejectedSkillsByUser = new HashMap<>(); + for (Qualification qualification : allowedQualifications) { validateWaitingStatus(qualification); + User owner = qualification.getUser(); var action = activeRequestsMap.get(qualification.getId()).action(); + String skillName = qualification.getSkill().getName(); switch (action) { - case QualificationUpdateAction.ACCEPT -> qualification.accept(); - case QualificationUpdateAction.REJECT -> qualification.reject(); + case ACCEPT -> { + qualification.accept(); + acceptedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } + case REJECT -> { + qualification.reject(); + rejectedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } } } + + acceptedSkillsByUser.forEach((user, skills) -> { + eventPublisher.publishEvent(new QualificationAcceptedEvent( + user.getId(), + user, + skills + )); + }); + + rejectedSkillsByUser.forEach((user, skills) -> { + eventPublisher.publishEvent(new QualificationRejectedEvent( + user.getId(), + user, + skills + )); + }); } @Transactional(readOnly = true) diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java index 36672239..4f7f94dc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.auth; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -11,7 +12,9 @@ import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.domain.entity.user.ActivationToken; import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.domain.event.SystemNewEmployeeEvent; 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.user.ActivationTokenRepository; @@ -34,6 +37,7 @@ public class AuthService { private final JwtService jwtService; private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; + private final ApplicationEventPublisher eventPublisher; @Transactional public TokenPair register(RegisterCommand command) { @@ -56,6 +60,14 @@ public TokenPair register(RegisterCommand command) { tokenRepository.delete(activationToken); + if (user.getUserRole() == UserRole.COMMON && user.getSupervisor() != null) { + eventPublisher.publishEvent(new SystemNewEmployeeEvent( + user.getId(), + user.getSupervisor(), + user.getFullName() + )); + } + UserDetails userDetails = new UserPrincipal( user.getId(), user.getEmail(), diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/RiskCommand.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/RiskCommand.java index e85e8b8d..4aad7a67 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/RiskCommand.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/command/project/RiskCommand.java @@ -3,6 +3,7 @@ public record RiskCommand( String name, String description, - Integer probability + Integer probability, + Integer impact ) { -} +} \ No newline at end of file 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 index aa45c8ce..e0fccfd8 100644 --- 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 @@ -9,9 +9,10 @@ public record SearchProjectCommand( UserPrincipal user, String query, UUID groupId, + Boolean isActive, Boolean unassignedOnly ) { - public SearchProjectCommand toCommand(UserPrincipal user, String query, UUID groupId, Boolean unassignedOnly) { - return new SearchProjectCommand(user, query, groupId, unassignedOnly); + public SearchProjectCommand toCommand(UserPrincipal user, String query, UUID groupId, Boolean isActive, Boolean unassignedOnly) { + return new SearchProjectCommand(user, query, groupId, isActive, unassignedOnly); } } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java index 6217a84d..7fe58050 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java @@ -34,7 +34,7 @@ public class NotificationService { @TransactionalEventListener @Transactional(propagation = Propagation.REQUIRES_NEW) public void onNotificationEvent(NotificationEvent event) { - create(event.recipient(), event.type(), event.message(), event.referenceId()); + create(event.recipient(), event.type(), event.buildMessage(), event.referenceId()); } private void create(User recipient, NotificationType type, String message, UUID referenceId) { @@ -51,6 +51,11 @@ private void create(User recipient, NotificationType type, String message, UUID notificationSender.send(response, recipient.getId()); } + @Transactional(readOnly = true) + public Integer getUnreadCount(UUID userId) { + return notificationRepository.countAllByRecipientIdAndIsReadFalse(userId); + } + @Transactional(readOnly = true) public PagedResponse getNotificationsForUser(UUID userId, int page, int size, boolean unreadOnly) { Pageable pageable = PageRequest.of(page, size); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java index 18e1e038..2091eeeb 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java @@ -5,6 +5,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.project.AssignmentResponse; +import pl.edu.agh.project_manager.controller.dto.project.AssignmentsByEmployeeResponse; +import pl.edu.agh.project_manager.controller.dto.project.ProjectAssignmentResponse; 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.entity.project.ProjectAssignment; @@ -15,12 +17,15 @@ 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.ProjectAssignmentRepository; +import pl.edu.agh.project_manager.repository.project.ProjectRepository; import pl.edu.agh.project_manager.service.user.UserService; import pl.edu.agh.project_manager.service.command.project.AssignmentCommand; import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -54,7 +59,8 @@ public AssignmentResponse createAssignment(UUID projectId, AssignmentCommand com eventPublisher.publishEvent(new AssignmentRequestedEvent( savedAssignment.getId(), user.getSupervisor(), - "Kierownik projektu " + project.getTitle() + " prosi o alokację pracownika " + user.getFullName() + project.getTitle(), + user.getFullName() )); } @@ -123,9 +129,17 @@ public void acceptAssignment(UUID assignmentId) { } eventPublisher.publishEvent(new AssignmentAcceptedEvent( - assignment.getId(), + project.getId(), project.getProjectManager(), - "Zaakceptowano przypisanie pracownika " + user.getFullName() + " do projektu " + project.getTitle() + project.getTitle(), + user.getFullName() + )); + + eventPublisher.publishEvent(new AssignmentAcceptedEvent( + project.getId(), + user, + project.getTitle(), + null )); } @@ -138,9 +152,10 @@ public void rejectAssignment(UUID assignmentId) { assignment.setStatus(AssignmentStatus.REJECTED); eventPublisher.publishEvent(new AssignmentRejectedEvent( - assignment.getId(), + assignment.getProject().getId(), assignment.getProject().getProjectManager(), - "Odrzucono przypisanie pracownika " + assignment.getUser().getName() + " do projektu " + assignment.getProject().getTitle() + assignment.getUser().getName(), + assignment.getProject().getTitle() )); } @@ -162,14 +177,14 @@ private void validatePendingStatus(ProjectAssignment assignment) { private void validateAssignmentDates(LocalDate startDate, LocalDate endDate, Project project) { if (startDate.isAfter(endDate)) { throw new ApplicationException( - ApiErrorCode.VALIDATION_ERROR, + ApiErrorCode.ASSIGNMENT_INVALID_DATES, "Start date cannot be after end date" ); } if (startDate.isBefore(project.getStartDate()) || endDate.isAfter(project.getEndDate())) { throw new ApplicationException( - ApiErrorCode.VALIDATION_ERROR, + ApiErrorCode.ASSIGNMENT_INVALID_DATES, "Assignment dates must fit within project start and end dates" ); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectRiskService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectRiskService.java index 68578438..9c9d6450 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectRiskService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectRiskService.java @@ -3,12 +3,11 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import pl.edu.agh.project_manager.controller.dto.project.RiskResponse; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; import pl.edu.agh.project_manager.domain.entity.project.Project; import pl.edu.agh.project_manager.domain.entity.project.ProjectRisk; 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.project.RiskRepository; import pl.edu.agh.project_manager.service.command.project.RiskCommand; @@ -23,22 +22,22 @@ public class ProjectRiskService { private final ProjectService projectService; @Transactional - public RiskResponse createProjectRisk(RiskCommand command, UUID projectId) { + public ProjectRiskResponse createProjectRisk(RiskCommand command, UUID projectId) { Project project = projectService.getProjectEntityOrThrow(projectId); ProjectRisk risk = buildRisk(command); risk.setProject(project); ProjectRisk savedRisk = riskRepository.save(risk); - return RiskResponse.from(savedRisk); + return ProjectRiskResponse.from(savedRisk); } - public List getProjectRisks(UUID projectId) { + public List getProjectRisks(UUID projectId) { projectService.checkProjectExistsOrThrow(projectId); return riskRepository.findAllByProjectId(projectId) .stream() - .map(RiskResponse::from) + .map(ProjectRiskResponse::from) .toList(); } @@ -54,7 +53,7 @@ public void deleteProjectRisk(UUID projectId, UUID riskId) { } @Transactional - public RiskResponse updateProjectRisk(UUID projectId, UUID riskId, RiskCommand command) { + public ProjectRiskResponse updateProjectRisk(UUID projectId, UUID riskId, RiskCommand command) { ProjectRisk risk = getRiskOrThrow(riskId); if (!risk.getProject().getId().equals(projectId)) { @@ -64,8 +63,9 @@ public RiskResponse updateProjectRisk(UUID projectId, UUID riskId, RiskCommand c if (command.name() != null) risk.setName(command.name()); if (command.description() != null) risk.setDescription(command.description()); if (command.probability() != null) risk.setProbability(command.probability()); + if (command.impact() != null) risk.setImpact(command.impact()); - return RiskResponse.from(risk); + return ProjectRiskResponse.from(risk); } private ProjectRisk getRiskOrThrow(UUID riskId) { @@ -78,6 +78,7 @@ private ProjectRisk buildRisk(RiskCommand command) { .name(command.name()) .description(command.description()) .probability(command.probability()) + .impact(command.impact()) .build(); } -} +} \ No newline at end of file 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 1db10fe6..c2cd03af 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,31 +1,31 @@ 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; +import org.springframework.transaction.annotation.Transactional; +import pl.edu.agh.project_manager.controller.dto.milestone.MilestoneResponse; +import pl.edu.agh.project_manager.controller.dto.project.*; import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; import pl.edu.agh.project_manager.domain.entity.project.ProjectRisk; 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.ProjectAssignmentRepository; 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.MilestoneCommand; 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.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; -import pl.edu.agh.project_manager.domain.enums.UserRole; +import java.util.stream.Collectors; + 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; @@ -37,7 +37,7 @@ public class ProjectService { private final UserService userService; private final ProjectGroupsService projectGroupService; private final ProjectRepository projectRepository; - + private final ProjectAssignmentRepository assignmentRepository; @Transactional public UUID createProject(ProjectCreationCommand command) { @@ -62,6 +62,7 @@ public UUID createProject(ProjectCreationCommand command) { return savedProject.getId(); } + @Transactional(readOnly = true) public ProjectResponse getProject(UUID projectId) { Project project = projectRepository.findByIdWithManager(projectId) .orElseThrow(() -> new ApplicationException( @@ -72,6 +73,7 @@ public ProjectResponse getProject(UUID projectId) { return ProjectResponse.from(project); } + @Transactional(readOnly = true) public ProjectMembersResponse getProjectMembers(UUID projectId) { Project project = projectRepository.findByIdWithAllMembers(projectId) .orElseThrow(() -> new ApplicationException( @@ -82,6 +84,44 @@ public ProjectMembersResponse getProjectMembers(UUID projectId) { return ProjectMembersResponse.from(project); } + @Transactional(readOnly = true) + public ProjectTimelineResponse getTimelineData(UUID projectId) { + Project project = projectRepository.findWithMilestonesById(projectId) + .orElseThrow(() -> new ApplicationException( + ApiErrorCode.PROJECT_NOT_FOUND, + "Cannot find provided project - " + projectId + )); + + List assignments = assignmentRepository.findActiveAndPendingByProjectIdOrderByCreatedAtAsc(projectId); + + return new ProjectTimelineResponse( + project.getMilestones().stream().map(MilestoneResponse::from).toList(), + groupAssignmentsByEmployee(assignments) + ); + } + + private List groupAssignmentsByEmployee(List assignments) { + Map> assignmentsByUser = assignments.stream() + .collect(Collectors.groupingBy(ProjectAssignment::getUser, LinkedHashMap::new, Collectors.toList())); + + return assignmentsByUser.entrySet().stream() + .map(entry -> { + User employee = entry.getKey(); + List userAssignments = entry.getValue(); + + return new AssignmentsByEmployeeResponse( + employee.getId(), + employee.getName(), + employee.getSurname(), + employee.getEmail(), + userAssignments.stream() + .map(ProjectAssignmentResponse::from) + .toList() + ); + }) + .toList(); + } + private Project buildProject(ProjectCreationCommand command, User projectManager) { return Project.builder() .title(command.title()) @@ -100,6 +140,7 @@ private void addRisksToProject(Project project, List risks) { .name(riskRequest.name()) .description(riskRequest.description()) .probability(riskRequest.probability()) + .impact(riskRequest.impact()) .build(); project.addRisk(risk); @@ -143,11 +184,9 @@ public Project getProjectEntityOrThrow(UUID projectId) { .orElseThrow(() -> new ApplicationException(ApiErrorCode.PROJECT_NOT_FOUND, "Cannot find project: " + projectId)); } - @Transactional + @Transactional(readOnly = true) public List searchProjects(SearchProjectCommand command) { - Specification spec = Specification - .where(ProjectSpecification.accessibleByUser(command.user())) - .and(buildSearchFilter(command)); + Specification spec = Specification.where(buildSearchFilter(command)); return projectRepository.findAll(spec).stream() .map(ProjectResponse::from) @@ -161,6 +200,10 @@ private Specification buildSearchFilter(SearchProjectCommand command) { spec = spec.and(ProjectSpecification.withSearchPattern(command.query())); } + if (command.isActive() != null) { + spec = spec.and(ProjectSpecification.isActive(command.isActive())); + } + if (command.groupId() != null) { spec = spec.and(ProjectSpecification.inGroup(command.groupId())); } else if (Boolean.TRUE.equals(command.unassignedOnly())) { @@ -169,4 +212,4 @@ private Specification buildSearchFilter(SearchProjectCommand command) { return spec; } -} +} \ No newline at end of file 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 index 1d27013e..733af66d 100644 --- 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 @@ -1,8 +1,10 @@ package pl.edu.agh.project_manager.service.project; -import jakarta.persistence.criteria.Join; import org.springframework.data.jpa.domain.Specification; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.project.ProjectMember; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.security.UserPrincipal; @@ -22,11 +24,20 @@ public static Specification accessibleByUser(UserPrincipal user) { case PROJECT_MANAGER -> cb.equal(root.get("projectManager").get("id"), user.userId()); case LINEAR_MANAGER, COMMON -> { - Join membersJoin = root.join("members"); + Join membersJoin = root.join("members"); query.distinct(true); - yield cb.equal(membersJoin.get("id"), user.userId()); + yield cb.equal(membersJoin.get("user").get("id"), user.userId()); } - case ADMINISTRATOR, AUTHORITY -> cb.conjunction(); + case AUTHORITY -> { + Join sponsorsJoin = root.join("sponsors", JoinType.LEFT); + Join committeesJoin = root.join("committees", JoinType.LEFT); + query.distinct(true); + yield cb.or( + cb.equal(sponsorsJoin.get("id"), user.userId()), + cb.equal(committeesJoin.get("id"), user.userId()) + ); + } + case ADMINISTRATOR -> cb.conjunction(); }; } @@ -38,4 +49,7 @@ public static Specification unassigned() { return (root, query, cb) -> cb.isNull(root.get("projectGroup")); } + public static Specification isActive(Boolean isActive) { + return (root, query, cb) -> cb.equal(root.get("isActive"), isActive); + } } 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 5617a53e..de6482dd 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 @@ -6,7 +6,8 @@ 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.controller.dto.project_group.GroupBasicResponse; +import pl.edu.agh.project_manager.controller.dto.project_group.ProjectGroupResponse; 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; @@ -17,9 +18,15 @@ 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 pl.edu.agh.project_manager.security.UserPrincipal; +import pl.edu.agh.project_manager.service.project.ProjectSpecification; +import org.springframework.data.jpa.domain.Specification; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; @Service @AllArgsConstructor @@ -29,15 +36,46 @@ public class ProjectGroupsService { private final UserRepository userRepository; private final ProjectRepository projectRepository; - public AllGroupsResponse getAllGroups() { - return new AllGroupsResponse(getWalletGroups(), getProgramGroups()); + @Transactional(readOnly = true) + public AllGroupsResponse getAllGroups(UserPrincipal userPrincipal) { + Specification spec = ProjectSpecification.accessibleByUser(userPrincipal); + + List accessibleProjects = projectRepository.findAll(spec); + + List unassigned = accessibleProjects.stream() + .filter(p -> p.getProjectGroup() == null) + .map(ProjectResponse::from) + .collect(Collectors.toList()); + + Map> groupedProjects = accessibleProjects.stream() + .filter(p -> p.getProjectGroup() != null) + .collect(Collectors.groupingBy(Project::getProjectGroup)); + + List wallets = groupedProjects.entrySet().stream() + .filter(entry -> entry.getKey().getGroupType() == GroupType.WALLET) + .map(entry -> mapToGroupResponse(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + List programs = groupedProjects.entrySet().stream() + .filter(entry -> entry.getKey().getGroupType() == GroupType.PROGRAM) + .map(entry -> mapToGroupResponse(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + + return new AllGroupsResponse(wallets, programs, unassigned); + } + + private ProjectGroupResponse mapToGroupResponse(ProjectGroup group, List projects) { + List projectDtos = projects.stream() + .map(ProjectResponse::from) + .collect(Collectors.toList()); + return new ProjectGroupResponse(group.getId(), group.getName(), projectDtos); } - public List getWalletGroups() { + public List getWalletGroups() { return getGroupsByType(GroupType.WALLET); } - public List getProgramGroups() { + public List getProgramGroups() { return getGroupsByType(GroupType.PROGRAM); } @@ -73,10 +111,10 @@ public UUID createGroup(ProjectGroupCreationCommand command) { return savedGroup.getId(); } - private List getGroupsByType(GroupType groupType) { + private List getGroupsByType(GroupType groupType) { return projectGroupRepository.getSingleGroupByGroupType(groupType) .stream() - .map(group -> new SingleGroupResponse(group.getId(), group.getName())) + .map(group -> new GroupBasicResponse(group.getId(), group.getName(), groupType)) .toList(); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/CsvGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/CsvGenerator.java new file mode 100644 index 00000000..0905e8e1 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/CsvGenerator.java @@ -0,0 +1,7 @@ +package pl.edu.agh.project_manager.service.report; + +import java.util.List; + +public interface CsvGenerator { + byte[] generate(List data); +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PdfGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PdfGenerator.java new file mode 100644 index 00000000..caacd5f5 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PdfGenerator.java @@ -0,0 +1,5 @@ +package pl.edu.agh.project_manager.service.report; + +public interface PdfGenerator { + byte[] generate(T data, String templateName); +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGenerator.java new file mode 100644 index 00000000..86e91777 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGenerator.java @@ -0,0 +1,50 @@ +package pl.edu.agh.project_manager.service.report; + +import com.opencsv.CSVWriter; +import org.springframework.stereotype.Component; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; +import pl.edu.agh.project_manager.domain.exception.ApplicationException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +public class PortfolioCsvGenerator implements CsvGenerator { + + private static final byte[] BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + private static final String[] HEADERS = {"Tytuł Projektu", "Opis", "Data rozpoczęcia", "Data zakończenia", "Status"}; + + @Override + public byte[] generate(List data) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + baos.write(BOM); + + try (OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8); + CSVWriter csvWriter = new CSVWriter(writer)) { + + csvWriter.writeNext(HEADERS); + + for (ProjectResponse project : data) { + String[] row = { + project.title(), + project.description(), + project.startDate().toString(), + project.endDate().toString(), + project.isActive() ? "Aktywny" : "Zakończony" + }; + csvWriter.writeNext(row); + } + + csvWriter.flush(); + } + + return baos.toByteArray(); + } catch (IOException e) { + throw new ApplicationException(ApiErrorCode.REPORT_GENERATION_ERROR, "Błąd podczas generowania pliku CSV portfela: " + e.getMessage()); + } + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ProjectPdfGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ProjectPdfGenerator.java new file mode 100644 index 00000000..f194b64f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ProjectPdfGenerator.java @@ -0,0 +1,39 @@ +package pl.edu.agh.project_manager.service.report; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; +import pl.edu.agh.project_manager.domain.exception.ApplicationException; + +import java.io.ByteArrayOutputStream; + +@Component +@RequiredArgsConstructor +public class ProjectPdfGenerator implements PdfGenerator { + + private final TemplateEngine templateEngine; + + @Override + public byte[] generate(ProjectResponse data, String templateName) { + Context context = new Context(); + context.setVariable("project", data); + + String htmlContent = templateEngine.process(templateName, context); + + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFastMode(); + builder.withHtmlContent(htmlContent, null); + builder.toStream(os); + builder.run(); + + return os.toByteArray(); + } catch (Exception e) { + throw new ApplicationException(ApiErrorCode.REPORT_GENERATION_ERROR, "Błąd podczas generowania pliku PDF: " + e.getMessage()); + } + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ReportService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ReportService.java new file mode 100644 index 00000000..c77c451f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/ReportService.java @@ -0,0 +1,46 @@ +package pl.edu.agh.project_manager.service.report; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; +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.ProjectRiskService; +import pl.edu.agh.project_manager.service.project.ProjectService; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ProjectService projectService; + private final ProjectRiskService projectRiskService; + private final RiskCsvGenerator riskCsvGenerator; + private final ProjectPdfGenerator projectPdfGenerator; + private final RiskPdfGenerator riskPdfGenerator; + private final PortfolioCsvGenerator portfolioCsvGenerator; + + public byte[] generateProjectRisksCsv(UUID projectId, UserPrincipal user) { + List risks = projectRiskService.getProjectRisks(projectId); + return riskCsvGenerator.generate(risks); + } + + public byte[] generateProjectCardPdf(UUID projectId, UserPrincipal user) { + ProjectResponse project = projectService.getProject(projectId); + return projectPdfGenerator.generate(project, "project-card-template"); + } + + public byte[] generateProjectRisksPdf(UUID projectId, UserPrincipal user) { + List risks = projectRiskService.getProjectRisks(projectId); + return riskPdfGenerator.generate(risks, "project-risks-template"); + } + + public byte[] generatePortfolioCsv(UUID groupId, UserPrincipal user) { + SearchProjectCommand command = new SearchProjectCommand(user, null, groupId, null, false); + List projects = projectService.searchProjects(command); + return portfolioCsvGenerator.generate(projects); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskCsvGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskCsvGenerator.java new file mode 100644 index 00000000..445a8f8c --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskCsvGenerator.java @@ -0,0 +1,48 @@ +package pl.edu.agh.project_manager.service.report; + +import com.opencsv.CSVWriter; +import org.springframework.stereotype.Component; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; +import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; +import pl.edu.agh.project_manager.domain.exception.ApplicationException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +public class RiskCsvGenerator implements CsvGenerator { + + private static final byte[] BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + private static final String[] HEADERS = {"Nazwa", "Opis", "Prawdopodobieństwo"}; + + @Override + public byte[] generate(List data) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + baos.write(BOM); + + try (OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8); + CSVWriter csvWriter = new CSVWriter(writer)) { + + csvWriter.writeNext(HEADERS); + + for (ProjectRiskResponse risk : data) { + String[] row = { + risk.name(), + risk.description(), + String.valueOf(risk.probability()) + }; + csvWriter.writeNext(row); + } + + csvWriter.flush(); + } + + return baos.toByteArray(); + } catch (IOException e) { + throw new ApplicationException(ApiErrorCode.REPORT_GENERATION_ERROR, "Błąd podczas generowania pliku CSV: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskPdfGenerator.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskPdfGenerator.java new file mode 100644 index 00000000..6c5e4a6a --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/report/RiskPdfGenerator.java @@ -0,0 +1,40 @@ +package pl.edu.agh.project_manager.service.report; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; +import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; +import pl.edu.agh.project_manager.domain.exception.ApplicationException; + +import java.io.ByteArrayOutputStream; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RiskPdfGenerator implements PdfGenerator> { + + private final TemplateEngine templateEngine; + + @Override + public byte[] generate(List data, String templateName) { + Context context = new Context(); + context.setVariable("risks", data); + + String htmlContent = templateEngine.process(templateName, context); + + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFastMode(); + builder.withHtmlContent(htmlContent, null); + builder.toStream(os); + builder.run(); + + return os.toByteArray(); + } catch (Exception e) { + throw new ApplicationException(ApiErrorCode.REPORT_GENERATION_ERROR, "Błąd podczas generowania pliku PDF z ryzykami: " + e.getMessage()); + } + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java index db8dc756..75ed424c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -58,6 +59,19 @@ public List addQualificationsToUser(UUID userId, List skillsList = savedQualifications.stream() + .map(q -> q.getSkill().getName()) + .toList(); + + eventPublisher.publishEvent(new QualificationRequestedEvent( + user.getId(), + user.getSupervisor(), + user.getFullName(), + skillsList + )); + } + return savedQualifications.stream() .map(q -> new QualificationResponse(q.getId(), q.getSkill().getName(), q.getStatus())) .toList(); @@ -72,15 +86,6 @@ private Qualification saveQualification(User user, Skill skill) { user.addQualification(qualification); Qualification saved = qualificationRepository.save(qualification); - // FIXME: na ten moment przy jednym zgłoszeniu 5 skilli przychodziłoby 5 powiadomień, raczej tak nie chcemy, ale na razie nie wiem jak będzie wyglądało to na froncie, więc zostawiam zakomentowane -// if (user.getSupervisor() != null) { -// eventPublisher.publishEvent(new QualificationRequestedEvent( -// saved.getId(), -// user.getSupervisor(), -// user.getFullName() + " zgłasza umiejętność: " + skill.getName() -// )); -// } - return saved; } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java index 3c9d794b..733482e7 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java @@ -26,34 +26,36 @@ public class UserInvitationService { private final UserRepository userRepository; private final ActivationTokenRepository tokenRepository; private final EmailSender emailSender; - private final ApplicationEventPublisher eventPublisher; @Transactional public void inviteUser(AdminInviteUserCommand command) { - processInvitation(command.email(), command.role(), command.supervisorId()); - } - - private void processInvitation(String email, UserRole role, UUID supervisorId) { - if (userRepository.existsByEmail(email)) { + if (userRepository.existsByEmail(command.email())) { throw new ApplicationException(ApiErrorCode.INVITATION_USER_ALREADY_EXISTS); } - var supervisor = fetchSupervisor(supervisorId); + User supervisor = fetchAndValidateSupervisor(command.supervisorId()); - var newUser = createInvitedUser(email, role, supervisor); + User newUser = createInvitedUser(command.email(), command.role(), supervisor); userRepository.save(newUser); - if (supervisor != null) { - eventPublisher.publishEvent(new SystemNewEmployeeEvent( - newUser.getId(), - supervisor, - "Do twojego zespołu zaproszono nowego pracownika: " + email - )); + var activationToken = createTokenForUser(newUser); + + sendInvitationEmail(command.email(), activationToken); + } + + private User fetchAndValidateSupervisor(UUID supervisorId) { + if (supervisorId == null) { + return null; } - var activationToken = createTokenForUser(newUser); + User supervisor = userRepository.findById(supervisorId) + .orElseThrow(() -> new ApplicationException(ApiErrorCode.USER_NOT_FOUND)); + + if (supervisor.getUserRole() != UserRole.LINEAR_MANAGER && supervisor.getUserRole() != UserRole.AUTHORITY) { + throw new ApplicationException(ApiErrorCode.INVALID_SUPERVISOR_ROLE); + } - sendInvitationEmail(email, activationToken); + return supervisor; } private String createTokenForUser(User user) { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java index 4f0b6d16..ec48027b 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java @@ -10,19 +10,34 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.PagedResponse; +import pl.edu.agh.project_manager.controller.dto.project.ProjectAssignmentUserWorkloadResponse; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.controller.dto.project.UserProjectMembershipResponse; +import pl.edu.agh.project_manager.controller.dto.project_group.OwnedGroupResponse; import pl.edu.agh.project_manager.controller.dto.user.SimpleUserResponse; import pl.edu.agh.project_manager.controller.dto.user.UserResponse; +import pl.edu.agh.project_manager.domain.entity.project.Project; +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.AssignmentStatus; import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; 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.ProjectAssignmentRepository; 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.util.assignments.AssignmentsUtil; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -30,6 +45,8 @@ public class UserService { private final UserRepository userRepository; private final ProjectRepository projectRepository; + private final ProjectAssignmentRepository projectAssignmentRepository; + private final ProjectGroupRepository projectGroupRepository; private final PasswordEncoder passwordEncoder; @Transactional @@ -108,6 +125,77 @@ public User getUserEntityOrThrow(UUID userId) { .orElseThrow(() -> new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + userId)); } + @Transactional(readOnly = true) + public UserResponse getUser(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + userId)); + return UserResponse.from(user); + } + + @Transactional(readOnly = true) + public List getSubordinates(UUID supervisorId) { + if (!userRepository.existsById(supervisorId)) { + throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + supervisorId); + } + return userRepository.findAllBySupervisor_Id(supervisorId).stream() + .map(UserResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getManagedProjects(UUID managerId) { + if (!userRepository.existsById(managerId)) { + throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + managerId); + } + return projectRepository.findAllByProjectManagerId(managerId).stream() + .map(ProjectResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getRelatedProjectGroups(UUID userId) { + if (!userRepository.existsById(userId)) { + throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + userId); + } + + List owned = + projectGroupRepository.findAllByOwner_IdOrderByNameAsc(userId); + + Map related = + new LinkedHashMap<>(); + owned.forEach(g -> related.put(g.getId(), g)); + + projectRepository.findAllByProjectManagerId(userId).stream() + .map(p -> p.getProjectGroup()) + .filter(g -> g != null) + .forEach(g -> related.putIfAbsent(g.getId(), g)); + + Set ownedIds = owned.stream().map(g -> g.getId()).collect(Collectors.toSet()); + + return related.values().stream() + .map(g -> OwnedGroupResponse.from(g, ownedIds.contains(g.getId()))) + .sorted(Comparator + .comparing(OwnedGroupResponse::isOwner).reversed() + .thenComparing(OwnedGroupResponse::name)) + .toList(); + } + + @Transactional(readOnly = true) + public List getProjectMemberships(UUID userId) { + if (!userRepository.existsById(userId)) { + throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND, "Cannot find user: " + userId); + } + List assignments = projectAssignmentRepository + .findAllByUserIdAndStatus(userId, AssignmentStatus.ACCEPTED); + + Map> byProject = assignments.stream() + .collect(Collectors.groupingBy(ProjectAssignment::getProject, LinkedHashMap::new, Collectors.toList())); + + return byProject.entrySet().stream() + .map(e -> UserProjectMembershipResponse.from(e.getKey(), e.getValue())) + .toList(); + } + public List getUsersByIdsOrThrow(List userIds, String errorMessage) { long uniqueCount = userIds.stream().distinct().count(); List users = userRepository.findAllById(userIds); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/util/assignments/AssignmentsUtil.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/util/assignments/AssignmentsUtil.java new file mode 100644 index 00000000..c064b288 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/util/assignments/AssignmentsUtil.java @@ -0,0 +1,60 @@ +package pl.edu.agh.project_manager.util.assignments; + +import pl.edu.agh.project_manager.controller.dto.common.ChartIntervalResponse; +import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +public class AssignmentsUtil { + public static List generateWorkloadSteps(List assignments) { + if (assignments.isEmpty()) return List.of(); + + List timeline = assignments.stream() + .flatMap(a -> Stream.of(a.getStartDate(), a.getEndDate())) + .distinct() + .sorted() + .toList(); + + List steps = new ArrayList<>(); + + for (int i = 0; i < timeline.size() - 1; i++) { + LocalDate start = timeline.get(i); + LocalDate end = timeline.get(i + 1); + + int totalPercent = assignments.stream() + .filter(a -> !a.getStartDate().isAfter(start) && !a.getEndDate().isBefore(end)) + .mapToInt(ProjectAssignment::getUtilizationPercentage) + .sum(); + + if (totalPercent > 0) { + steps.add(new ChartIntervalResponse(start, end, totalPercent)); + } + } + + return mergeContinuousSteps(steps); + } + + private static List mergeContinuousSteps(List steps) { + if (steps.size() < 2) return steps; + + List merged = new ArrayList<>(); + ChartIntervalResponse current = steps.getFirst(); + + for (int i = 1; i < steps.size(); i++) { + ChartIntervalResponse next = steps.get(i); + + if (current.percentage() == next.percentage() && current.endDate().equals(next.startDate())) { + current = new ChartIntervalResponse(current.startDate(), next.endDate(), current.percentage()); + } else { + merged.add(current); + current = next; + } + } + merged.add(current); + + return merged; + } +} diff --git a/backend/project-manager/src/main/resources/application.properties b/backend/project-manager/src/main/resources/application.properties index baacaeb9..7a4af714 100644 --- a/backend/project-manager/src/main/resources/application.properties +++ b/backend/project-manager/src/main/resources/application.properties @@ -5,6 +5,8 @@ springdoc.swagger-ui.path=/swagger springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true application.security.swagger.enabled=true +logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=FATAL +logging.level.org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]=FATAL # Jwt diff --git a/backend/project-manager/src/main/resources/templates/project-card-template.html b/backend/project-manager/src/main/resources/templates/project-card-template.html new file mode 100644 index 00000000..458cf006 --- /dev/null +++ b/backend/project-manager/src/main/resources/templates/project-card-template.html @@ -0,0 +1,37 @@ + + + + + Karta Projektu + + + + +

Nazwa projektu (placeholder)

+ +
+

Opis: ...

+

+ Status: + ... +

+

Data rozpoczęcia: ...

+

Data zakończenia: ...

+
+ +
+

Kierownik Projektu

+

Imię i nazwisko: ...

+

Email: ...

+
+ + + \ No newline at end of file diff --git a/backend/project-manager/src/main/resources/templates/project-risks-template.html b/backend/project-manager/src/main/resources/templates/project-risks-template.html new file mode 100644 index 00000000..e754d584 --- /dev/null +++ b/backend/project-manager/src/main/resources/templates/project-risks-template.html @@ -0,0 +1,87 @@ + + + + + Rejestr Ryzyk Projektu + + + +
+

Rejestr Ryzyk Projektu

+ + + + + + + + + + + + + + + + + + + +
NazwaOpisPrawdopodobieństwo (%)
Ryzyko techniczneProblem z integracją zewnętrznego API.75
Brak zdefiniowanych ryzyk dla tego projektu.
+ + +
+ + diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementServiceTest.java index aa4710da..7f64728c 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/approval/AssignmentManagementServiceTest.java @@ -6,7 +6,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import pl.edu.agh.project_manager.controller.dto.employee_requests.ChartIntervalResponse; +import pl.edu.agh.project_manager.controller.dto.common.ChartIntervalResponse; import pl.edu.agh.project_manager.controller.dto.employee_requests.EmployeeAssignmentDetailsResponse; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.entity.project.ProjectAssignment; diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java index a325062e..a4f9b707 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java @@ -46,9 +46,16 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { User recipient = new User(); recipient.setId(UUID.randomUUID()); UUID assignmentId = UUID.randomUUID(); - String message = "Prośba o przypisanie"; - AssignmentRequestedEvent event = new AssignmentRequestedEvent(assignmentId, recipient, message); + String projectName = "Projekt Apollo"; + String employeeName = "Jan Kowalski"; + + AssignmentRequestedEvent event = new AssignmentRequestedEvent( + assignmentId, + recipient, + projectName, + employeeName + ); ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); @@ -56,11 +63,12 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { .id(UUID.randomUUID()) .recipient(recipient) .type(NotificationType.ASSIGNMENT_REQUESTED) - .message(message) + .message(event.buildMessage()) .referenceId(assignmentId) .isRead(false) .createdAt(LocalDateTime.now()) .build(); + when(notificationRepository.save(any(Notification.class))).thenReturn(savedMock); // When @@ -69,8 +77,11 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { // Then verify(notificationRepository).save(notificationCaptor.capture()); Notification capturedNotification = notificationCaptor.getValue(); + assertEquals(NotificationType.ASSIGNMENT_REQUESTED, capturedNotification.getType()); + assertEquals("Kierownik projektu Projekt Apollo prosi o alokację pracownika Jan Kowalski", capturedNotification.getMessage()); + verify(notificationSender).send(any(NotificationResponse.class), eq(recipient.getId())); } diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentServiceTest.java index 53267848..57d6ee6b 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentServiceTest.java @@ -97,7 +97,7 @@ void createAssignment_DatesOutOfRange() { assertThatExceptionOfType(ApplicationException.class) .isThrownBy(() -> assignmentService.createAssignment(projectId, command)) .extracting(ApplicationException::getErrorCode) - .isEqualTo(ApiErrorCode.VALIDATION_ERROR); + .isEqualTo(ApiErrorCode.ASSIGNMENT_INVALID_DATES); verify(assignmentRepository, never()).save(any()); } diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectRiskServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectRiskServiceTest.java index f6754167..d3771932 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectRiskServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/project/ProjectRiskServiceTest.java @@ -6,7 +6,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import pl.edu.agh.project_manager.controller.dto.project.RiskResponse; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; import pl.edu.agh.project_manager.domain.entity.project.Project; import pl.edu.agh.project_manager.domain.entity.project.ProjectRisk; import pl.edu.agh.project_manager.repository.project.RiskRepository; @@ -57,24 +57,28 @@ void updateProjectRisk_Success() { // Given UUID projectId = UUID.randomUUID(); UUID riskId = UUID.randomUUID(); - RiskCommand command = new RiskCommand("New Name", "New Desc", 90); + RiskCommand command = new RiskCommand("New Name", "New Desc", 4, 5); Project project = Project.builder().id(projectId).build(); ProjectRisk risk = ProjectRisk.builder() .id(riskId) .name("Old") .project(project) + .probability(1) + .impact(1) .build(); when(riskRepository.findById(riskId)).thenReturn(Optional.of(risk)); // When - RiskResponse response = riskService.updateProjectRisk(projectId, riskId, command); + ProjectRiskResponse response = riskService.updateProjectRisk(projectId, riskId, command); // Then assertThat(response.name()).isEqualTo("New Name"); assertThat(risk.getName()).isEqualTo("New Name"); - assertThat(risk.getProbability()).isEqualTo(90); + assertThat(risk.getProbability()).isEqualTo(4); + assertThat(risk.getImpact()).isEqualTo(5); + assertThat(response.value()).isEqualTo(20); } @Test @@ -82,12 +86,15 @@ void updateProjectRisk_Success() { void createProjectRisk_Success() { // Given UUID projectId = UUID.randomUUID(); - RiskCommand command = new RiskCommand("Title", "Desc", 50); + RiskCommand command = new RiskCommand("Title", "Desc", 3, 4); Project project = Project.builder().id(projectId).build(); ProjectRisk savedRisk = ProjectRisk.builder() .id(UUID.randomUUID()) .name("Title") + .description("Desc") + .probability(3) + .impact(4) .project(project) .build(); @@ -95,10 +102,11 @@ void createProjectRisk_Success() { when(riskRepository.save(any(ProjectRisk.class))).thenReturn(savedRisk); // When - RiskResponse response = riskService.createProjectRisk(command, projectId); + ProjectRiskResponse response = riskService.createProjectRisk(command, projectId); // Then assertThat(response.id()).isEqualTo(savedRisk.getId()); + assertThat(response.value()).isEqualTo(12); verify(riskRepository).save(any(ProjectRisk.class)); } } \ No newline at end of file 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 fd36abb6..c78096ee 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 @@ -56,7 +56,7 @@ void createProject_Success() { LocalDate.now(), null, null, - new ArrayList<>(List.of(new RiskCommand("Risk", "Desc", 50))), + new ArrayList<>(List.of(new RiskCommand("Risk", "Desc", 5, 3))), new ArrayList<>(List.of(new MilestoneCommand("Start", "Start desc", LocalDate.now()))), new ArrayList<>(), // Sponsors new ArrayList<>() // Committee @@ -101,7 +101,7 @@ void getAccessibleProjects_Administrator() { // Given UUID adminId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(adminId, UserRole.ADMINISTRATOR); - SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, true, false); when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A"), @@ -120,7 +120,7 @@ void getAccessibleProjects_ProjectManager() { // Given UUID pmId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(pmId, UserRole.PROJECT_MANAGER); - SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, true, false); when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A") @@ -139,7 +139,7 @@ void getAccessibleProjects_CommonUser() { // Given UUID userId = UUID.randomUUID(); UserPrincipal principal = createPrincipal(userId, UserRole.COMMON); - SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, false); + SearchProjectCommand searchCommand = new SearchProjectCommand(principal, null, null, true, false); when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of( createTestProject(UUID.randomUUID(), "Projekt A") 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 360ee143..dbf3ee1b 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 @@ -6,17 +6,22 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import pl.edu.agh.project_manager.controller.dto.project_group.AllGroupsResponse; import pl.edu.agh.project_manager.controller.dto.project_group.SingleGroupDetailsResponse; +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.enums.UserRole; 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 pl.edu.agh.project_manager.security.UserPrincipal; +import org.springframework.data.jpa.domain.Specification; import java.util.ArrayList; import java.util.List; @@ -70,11 +75,40 @@ void getGroupById_Success() { } @Test - @DisplayName("Should corect group types to WALLET and PROGRAMS") + @DisplayName("Should correct group types and filter projects by access, including unassigned") + @SuppressWarnings("unchecked") void getGroupTypes_Success() { UUID walletId = UUID.randomUUID(); UUID programId = UUID.randomUUID(); + UUID projectId1 = UUID.randomUUID(); + UUID projectId2 = UUID.randomUUID(); + UUID projectId3 = UUID.randomUUID(); User owner = User.builder().name("John").surname("Doe").email("john@doe.com").build(); + + UserPrincipal userPrincipal = new UserPrincipal( + UUID.randomUUID(), + "test@test.com", + "password", + "John", + "Doe", + List.of(new SimpleGrantedAuthority("ROLE_COMMON")), + UserRole.COMMON + ); + + Project project1 = new Project(); + project1.setId(projectId1); + project1.setTitle("P1"); + project1.setProjectManager(owner); + + Project project2 = new Project(); + project2.setId(projectId2); + project2.setTitle("P2"); + project2.setProjectManager(owner); + + Project project3 = new Project(); + project3.setId(projectId3); + project3.setTitle("P3"); + project3.setProjectManager(owner); ProjectGroup wallet = ProjectGroup.builder() .id(walletId) @@ -82,7 +116,10 @@ void getGroupTypes_Success() { .description("Description") .owner(owner) .groupType(GroupType.WALLET) + .projects(new ArrayList<>(List.of(project1, project2))) .build(); + project1.setProjectGroup(wallet); + project2.setProjectGroup(wallet); ProjectGroup program = ProjectGroup.builder() .id(programId) @@ -90,19 +127,28 @@ void getGroupTypes_Success() { .description("Description") .owner(owner) .groupType(GroupType.PROGRAM) + .projects(new ArrayList<>(List.of(project2))) .build(); + project2.setProjectGroup(program); - when(projectGroupRepository.getSingleGroupByGroupType(GroupType.WALLET)).thenReturn(List.of(wallet)); - when(projectGroupRepository.getSingleGroupByGroupType(GroupType.PROGRAM)).thenReturn(List.of(program)); + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of(project1, project2, project3)); // When - AllGroupsResponse response = projectGroupsService.getAllGroups(); + AllGroupsResponse response = projectGroupsService.getAllGroups(userPrincipal); // Then assertThat(response.wallets()).hasSize(1); - assertThat(response.programs()).hasSize(1); assertThat(response.wallets().getFirst().id()).isEqualTo(wallet.getId()); + assertThat(response.wallets().getFirst().projects()).hasSize(1); + assertThat(response.wallets().getFirst().projects().getFirst().id()).isEqualTo(projectId1); + + assertThat(response.programs()).hasSize(1); assertThat(response.programs().getFirst().id()).isEqualTo(program.getId()); + assertThat(response.programs().getFirst().projects()).hasSize(1); + assertThat(response.programs().getFirst().projects().getFirst().id()).isEqualTo(projectId2); + + assertThat(response.unassigned()).hasSize(1); + assertThat(response.unassigned().getFirst().id()).isEqualTo(projectId3); } @Test @@ -159,4 +205,4 @@ void createGroup_UserNotFound() { verify(projectGroupRepository, never()).save(any()); } -} \ No newline at end of file +} diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGeneratorTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGeneratorTest.java new file mode 100644 index 00000000..bc3e872d --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/PortfolioCsvGeneratorTest.java @@ -0,0 +1,54 @@ +package pl.edu.agh.project_manager.service.report; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class PortfolioCsvGeneratorTest { + + private PortfolioCsvGenerator portfolioCsvGenerator; + + @BeforeEach + void setUp() { + portfolioCsvGenerator = new PortfolioCsvGenerator(); + } + + @Test + @DisplayName("Should generate CSV for portfolio correctly") + void generatePortfolioCsv_Success() { + // Given + ProjectResponse project1 = new ProjectResponse(UUID.randomUUID(), "Projekt A", "Opis A", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 31), true, null, null); + ProjectResponse project2 = new ProjectResponse(UUID.randomUUID(), "Projekt B", "Opis B", LocalDate.of(2023, 5, 10), LocalDate.of(2025, 5, 9), false, null, null); + List projects = List.of(project1, project2); + + // When + byte[] resultBytes = portfolioCsvGenerator.generate(projects); + + // Then + assertThat(resultBytes).isNotNull(); + assertThat(resultBytes.length).isGreaterThan(0); + + // Verify BOM + assertThat(resultBytes[0]).isEqualTo((byte) 0xEF); + assertThat(resultBytes[1]).isEqualTo((byte) 0xBB); + assertThat(resultBytes[2]).isEqualTo((byte) 0xBF); + + // Verify content + String content = new String(resultBytes, 3, resultBytes.length - 3, StandardCharsets.UTF_8); + String[] lines = content.split("\n"); + + assertThat(lines).hasSize(3); // Headers + 2 rows + assertThat(lines[0]).contains("\"Tytuł Projektu\",\"Opis\",\"Data rozpoczęcia\",\"Data zakończenia\",\"Status\""); + assertThat(lines[1]).contains("\"Projekt A\",\"Opis A\",\"2024-01-01\",\"2024-12-31\",\"Aktywny\""); + assertThat(lines[2]).contains("\"Projekt B\",\"Opis B\",\"2023-05-10\",\"2025-05-09\",\"Zakończony\""); + } + +} diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ProjectPdfGeneratorTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ProjectPdfGeneratorTest.java new file mode 100644 index 00000000..acf56331 --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ProjectPdfGeneratorTest.java @@ -0,0 +1,68 @@ +package pl.edu.agh.project_manager.service.report; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.controller.dto.user.UserResponse; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectPdfGeneratorTest { + + @Mock + private TemplateEngine templateEngine; + + @InjectMocks + private ProjectPdfGenerator projectPdfGenerator; + + private ProjectResponse projectData; + + @BeforeEach + void setUp() { + UserResponse manager = new UserResponse( + UUID.randomUUID(), + "john.doe@example.com", + "John", + "Doe", + null, + null, + null, + null, + List.of() + ); + projectData = new ProjectResponse(UUID.randomUUID(), "Test Project", "Description", LocalDate.now(), LocalDate.now().plusMonths(6), true, manager, null); + } + + @Test + @DisplayName("Should generate PDF from HTML content") + void generatePdf_Success() { + // Given + String templateName = "test-template"; + String htmlContent = "

Test Project

"; + when(templateEngine.process(eq(templateName), any(Context.class))).thenReturn(htmlContent); + + // When + byte[] pdfBytes = projectPdfGenerator.generate(projectData, templateName); + + // Then + assertThat(pdfBytes).isNotNull(); + assertThat(pdfBytes.length).isGreaterThan(0); + + assertThat(new String(pdfBytes, 0, 4)).isEqualTo("%PDF"); + } +} diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ReportServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ReportServiceTest.java new file mode 100644 index 00000000..34836b2c --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/ReportServiceTest.java @@ -0,0 +1,131 @@ +package pl.edu.agh.project_manager.service.report; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import pl.edu.agh.project_manager.controller.dto.project.ProjectResponse; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; +import pl.edu.agh.project_manager.domain.enums.UserRole; +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.ProjectRiskService; +import pl.edu.agh.project_manager.service.project.ProjectService; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ProjectService projectService; + + @Mock + private ProjectRiskService projectRiskService; + + @Mock + private RiskCsvGenerator riskCsvGenerator; + + @Mock + private ProjectPdfGenerator projectPdfGenerator; + + @Mock + private RiskPdfGenerator riskPdfGenerator; + + @Mock + private PortfolioCsvGenerator portfolioCsvGenerator; + + @InjectMocks + private ReportService reportService; + + private UUID projectId; + private UserPrincipal normalUser; + + @BeforeEach + void setUp() { + projectId = UUID.randomUUID(); + normalUser = new UserPrincipal(UUID.randomUUID(), "user@test.com", "pass", "U", "U", List.of(new SimpleGrantedAuthority("ROLE_COMMON")), UserRole.COMMON); + } + + @Test + @DisplayName("Should generate risks CSV successfully") + void generateProjectRisksCsv() { + // Given + List risks = List.of(new ProjectRiskResponse(UUID.randomUUID(), "R1", "D1", 50, 21, 2)); + when(projectRiskService.getProjectRisks(projectId)).thenReturn(risks); + byte[] expectedCsv = "test-csv".getBytes(); + when(riskCsvGenerator.generate(risks)).thenReturn(expectedCsv); + + // When + byte[] result = reportService.generateProjectRisksCsv(projectId, normalUser); + + // Then + assertThat(result).isEqualTo(expectedCsv); + verify(projectRiskService).getProjectRisks(projectId); + verify(riskCsvGenerator).generate(risks); + } + + @Test + @DisplayName("Should generate project card PDF successfully") + void generateProjectCardPdf() { + // Given + ProjectResponse project = new ProjectResponse(projectId, "Test", "Desc", null, null, true, null, null); + when(projectService.getProject(projectId)).thenReturn(project); + byte[] expectedPdf = "test-pdf".getBytes(); + when(projectPdfGenerator.generate(project, "project-card-template")).thenReturn(expectedPdf); + + // When + byte[] result = reportService.generateProjectCardPdf(projectId, normalUser); + + // Then + assertThat(result).isEqualTo(expectedPdf); + verify(projectService).getProject(projectId); + verify(projectPdfGenerator).generate(project, "project-card-template"); + } + + @Test + @DisplayName("Should generate risks PDF successfully") + void generateProjectRisksPdf() { + // Given + List risks = List.of(new ProjectRiskResponse(UUID.randomUUID(), "R1", "D1", 50, 27, 2)); + when(projectRiskService.getProjectRisks(projectId)).thenReturn(risks); + byte[] expectedPdf = "test-pdf".getBytes(); + when(riskPdfGenerator.generate(risks, "project-risks-template")).thenReturn(expectedPdf); + + // When + byte[] result = reportService.generateProjectRisksPdf(projectId, normalUser); + + // Then + assertThat(result).isEqualTo(expectedPdf); + verify(projectRiskService).getProjectRisks(projectId); + verify(riskPdfGenerator).generate(risks, "project-risks-template"); + } + + @Test + @DisplayName("Should generate portfolio CSV successfully") + void generatePortfolioCsv() { + // Given + UUID groupId = UUID.randomUUID(); + List projects = List.of(new ProjectResponse(projectId, "Test", "Desc", null, null, true, null, null)); + when(projectService.searchProjects(any(SearchProjectCommand.class))).thenReturn(projects); + byte[] expectedCsv = "test-csv".getBytes(); + when(portfolioCsvGenerator.generate(projects)).thenReturn(expectedCsv); + + // When + byte[] result = reportService.generatePortfolioCsv(groupId, normalUser); + + // Then + assertThat(result).isEqualTo(expectedCsv); + verify(projectService).searchProjects(any(SearchProjectCommand.class)); + verify(portfolioCsvGenerator).generate(projects); + } +} \ No newline at end of file diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/RiskCsvGeneratorTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/RiskCsvGeneratorTest.java new file mode 100644 index 00000000..cbc36ef4 --- /dev/null +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/report/RiskCsvGeneratorTest.java @@ -0,0 +1,71 @@ +package pl.edu.agh.project_manager.service.report; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import pl.edu.agh.project_manager.controller.dto.project_risk.ProjectRiskResponse; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class RiskCsvGeneratorTest { + + private RiskCsvGenerator riskCsvGenerator; + + @BeforeEach + void setUp() { + riskCsvGenerator = new RiskCsvGenerator(); + } + + @Test + @DisplayName("Should generate CSV content correctly") + void generateCsv_Success() { + // Given + ProjectRiskResponse risk1 = new ProjectRiskResponse(UUID.randomUUID(), "Risk 1", "Description 1", 50,21,2); + ProjectRiskResponse risk2 = new ProjectRiskResponse(UUID.randomUUID(), "Risk 2", "Description 2", 80,21,3); + List risks = List.of(risk1, risk2); + + // When + byte[] resultBytes = riskCsvGenerator.generate(risks); + + // Then + assertThat(resultBytes).isNotNull(); + assertThat(resultBytes.length).isGreaterThan(0); + + // Verify BOM + assertThat(resultBytes[0]).isEqualTo((byte) 0xEF); + assertThat(resultBytes[1]).isEqualTo((byte) 0xBB); + assertThat(resultBytes[2]).isEqualTo((byte) 0xBF); + + // Verify content + String content = new String(resultBytes, 3, resultBytes.length - 3, StandardCharsets.UTF_8); + String[] lines = content.split("\n"); + + assertThat(lines).hasSize(3); // Headers + 2 rows + assertThat(lines[0]).contains("\"Nazwa\",\"Opis\",\"Prawdopodobieństwo\""); + assertThat(lines[1]).contains("\"Risk 1\",\"Description 1\",\"50\""); + assertThat(lines[2]).contains("\"Risk 2\",\"Description 2\",\"80\""); + } + + @Test + @DisplayName("Should generate empty CSV with headers when list is empty") + void generateCsv_EmptyList() { + // Given + List risks = List.of(); + + // When + byte[] resultBytes = riskCsvGenerator.generate(risks); + + // Then + assertThat(resultBytes).isNotNull(); + + String content = new String(resultBytes, 3, resultBytes.length - 3, StandardCharsets.UTF_8); + String[] lines = content.split("\n"); + + assertThat(lines).hasSize(1); + assertThat(lines[0]).contains("\"Nazwa\",\"Opis\",\"Prawdopodobieństwo\""); + } +} diff --git a/frontend/index.html b/frontend/index.html index 0fca6f04..4f5da590 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + IO Project Manager
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e47ddf73..58056b24 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,18 +11,22 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.2.1", "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "radix-ui": "^1.4.3", "react": "^19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.4", - "react-hook-form": "^7.72.1", + "react-hook-form": "^7.75.0", "react-router-dom": "^7.14.1", "recharts": "^3.8.1", "shadcn": "^4.4.0", @@ -30,7 +34,7 @@ "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "use-debounce": "^10.1.1", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -455,6 +459,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.61.2", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.2.tgz", @@ -1078,6 +1088,12 @@ "@tanstack/react-query": ">= 4.0.0" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -4176,12 +4192,12 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -4496,6 +4512,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -4804,6 +4836,16 @@ "node": ">= 12" } }, + "node_modules/date-fns": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.2.1.tgz", + "integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5428,12 +5470,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5543,9 +5585,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6065,9 +6107,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6192,9 +6234,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -7496,9 +7538,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -7788,6 +7830,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -7801,9 +7869,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.72.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", - "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -9385,9 +9453,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 3a26e2d7..a2f7f9ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,18 +13,22 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.2.1", "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "radix-ui": "^1.4.3", "react": "^19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.4", - "react-hook-form": "^7.72.1", + "react-hook-form": "^7.75.0", "react-router-dom": "^7.14.1", "recharts": "^3.8.1", "shadcn": "^4.4.0", @@ -32,7 +36,7 @@ "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "use-debounce": "^10.1.1", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..15944c14 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 57a68d0c..d433c69c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,17 +4,37 @@ import {ENDPOINTS} from "./endpoints.ts"; let currentAccessToken: string | null = null; export const setAccessToken = (token: string | null) => { - currentAccessToken = token; + currentAccessToken = token; } +export const getAccessToken = () => currentAccessToken; + +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; + const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, withCredentials: true, }); +export const refreshAccessToken = async () => { + try { + const refreshResponse = await axios.post( + `${API_BASE_URL}${ENDPOINTS.AUTH.REFRESH}`, + {}, + { withCredentials: true } + ); + const newAccessToken = refreshResponse.data.accessToken; + setAccessToken(newAccessToken); + return newAccessToken; + } catch (error) { + setAccessToken(null); + window.dispatchEvent(new Event('auth-logout')); + throw error; + } +}; api.interceptors.request.use( (config) => { @@ -42,24 +62,10 @@ api.interceptors.response.use( originalRequest._retry = true; try { - const refreshResponse = await axios.post( - `${api.defaults.baseURL}${ENDPOINTS.AUTH.REFRESH}`, - {}, - { withCredentials: true } - ); - - const newAccessToken = refreshResponse.data.accessToken; - setAccessToken(newAccessToken) - + const newAccessToken = await refreshAccessToken(); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); - } catch (refreshError) { - setAccessToken(null); - - window.dispatchEvent(new Event('auth-logout')); - return Promise.reject(refreshError); } } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 08a2d107..937b01ed 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -9,9 +9,16 @@ export const ENDPOINTS = { LIST: '/users', DETAIL: (id: string) => `/users/${id}`, RESEND_INVITATION: '/users/invitation', - SEARCH_USERS: '/users/search' + WORKLOAD: (id?: string) => `/users/${id}/workload`, + SEARCH_USERS: '/users/search', + QUALIFICATIONS: (id: string) => `/users/${id}/qualifications`, + SUBORDINATES: (id: string) => `/users/${id}/subordinates`, + PROJECTS: (id: string) => `/users/${id}/projects`, + MEMBERSHIPS: (id: string) => `/users/${id}/memberships`, + GROUPS: (id: string) => `/users/${id}/groups`, }, ME: { + PROFILE: '/me', QUALIFICATIONS: '/me/qualifications', QUALIFICATION: (id: string) => `/me/qualifications/${id}`, PASSWORD: '/me/password', @@ -22,15 +29,13 @@ export const ENDPOINTS = { }, PROJECT: { BASE: '/projects', - DETAIL: (id: string) => `/projects/${id}`, - RISK: { - LIST: (projectId: string) => `/projects/${projectId}/risks` - }, - MEMBERS: (projectId: string) => `/projects/${projectId}/members`, - ROLES: { - STATUS_LIST: (projectId: string) => `/projects/${projectId}/roles/status`, - ALLOCATE: (roleId: string) => `/roles/${roleId}/allocation-requests` - } + DETAILS: (id: string) => ({ + BASE: `/projects/${id}`, + RISKS: `/projects/${id}/risks`, + MEMBERS: `/projects/${id}/members`, + ASSIGNMENTS: `/projects/${id}/assignments`, + TIMELINE: `/projects/${id}/timeline` + }), }, PROJECT_GROUP: { LIST_ALL: '/groups', @@ -47,5 +52,12 @@ export const ENDPOINTS = { QUALIFICATIONS: '/approvals/qualifications', QUALIFICATION_DETAILS: '/approvals/qualifications/details', QUALIFICATIONS_BULK_UPDATE: '/approvals/qualifications/bulk-update' + }, + NOTIFICATIONS: { + BASE: '/notifications', + STREAM: '/notifications/stream', + UNREAD_COUNT: 'notifications/unread-count', + MARK_READ: (id: string) => `/notifications/${id}/mark-as-read`, + MARK_ALL_READ: '/notifications/mark-all-as-read', } } as const; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c7c39c6d..4c822808 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -6,7 +6,8 @@ export const employeeAssignmentsKeys = createQueryKeys('employee-assignments', { }) export const qualificationsKeys = createQueryKeys('qualifications', { - mine: null, // me/qualifications + mine: null, + byUser: (userId: string) => [userId], suggestions: (query: string) => [query], }); @@ -15,5 +16,10 @@ export const approvalsKeys = createQueryKeys('approvals', { details: (userId: string) => [userId], }); +export const notificationsKeys = createQueryKeys('notifications', { + feed: (unreadOnly: boolean, page: number) => [{ unreadOnly, page }], + unreadCount: () => ['count'], + infinite: (unreadOnly: boolean) => [{ unreadOnly, type: 'infinite' }], +}); -export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, qualificationsKeys, approvalsKeys); \ No newline at end of file +export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, qualificationsKeys, approvalsKeys, notificationsKeys) \ No newline at end of file diff --git a/frontend/src/api/sseClient.ts b/frontend/src/api/sseClient.ts new file mode 100644 index 00000000..d2d34a69 --- /dev/null +++ b/frontend/src/api/sseClient.ts @@ -0,0 +1,70 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { getAccessToken, refreshAccessToken } from './client'; + +interface SSEConnectionOptions { + url: string; + onOpen?: () => void; + onMessage?: (event: { event: string; data: string }) => void; + onError?: (error: unknown) => void; +} + +export const connectSSE = ({ url, onOpen, onMessage, onError }: SSEConnectionOptions) => { + let controller = new AbortController(); + + const connect = async () => { + const token = getAccessToken(); + if (!token) return; + + try { + await fetchEventSource(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'text/event-stream, application/json', + }, + signal: controller.signal, + + async onopen(response) { + if (response.ok && onOpen) { + onOpen(); + } + + if (response.status === 401) { + try { + await refreshAccessToken(); + controller.abort(); + controller = new AbortController(); + setTimeout(connect, 100); + return; + } catch (err) { + throw new Error('Refresh failed - stop retrying'); + } + } + + if (response.status >= 400 && response.status !== 401) { + throw new Error('Server Error - Stop retrying'); + } + }, + + onmessage(ev) { + if (onMessage) { + onMessage(ev); + } + }, + + onerror(err) { + if (onError) onError(err); + throw err; + } + }); + } catch (error) { + console.error('SSE Connection failed', error); + } + }; + + connect(); + + return () => { + controller.abort(); + }; +}; \ No newline at end of file diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index ed36ba5c..56348f0f 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,7 +1,10 @@ import { Outlet } from 'react-router-dom'; import { Navbar } from './Navbar'; +import { useNotificationStream } from '@/features/notification/notification.hooks'; export const MainLayout = () => { + useNotificationStream(); + return (
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index 78b432b6..ef84f296 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -1,9 +1,8 @@ -import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { useAuth } from '@/providers/AuthContext'; import { useAuthActions } from '@/features/auth/auth.hooks'; import { PATHS } from '@/routes/paths'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, @@ -17,29 +16,25 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LogOut, Briefcase, - Bell, - UserCircle + UserCircle, + ChevronDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { NAV_ITEMS } from '@/routes/navigation'; +import { NotificationBell } from "@/features/notification/components/NotificationBell.tsx"; export const Navbar = () => { const { user } = useAuth(); const { logoutUser, isLoggingOut } = useAuthActions(); - const navigate = useNavigate(); const location = useLocation(); const isActive = (path: string) => location.pathname.startsWith(path); - // TODO: powiadomienia - const unreadNotifications = 3; - const initials = `${user?.firstName?.charAt(0) || ''}${user?.lastName?.charAt(0) || ''}`.toUpperCase(); - const visibleNavItems = NAV_ITEMS.filter((item) => { - if (!item.roles) return true; - if (!user) return false; - return item.roles.includes(user.role); + if (item.roles && user && !item.roles.includes(user.role)) return false; + if (item.children && item.children.length === 0) return false; + return true; }); return ( @@ -54,19 +49,66 @@ export const Navbar = () => {
{visibleNavItems.map((item) => { - const active = location.pathname === item.path && isActive(item.path); + if (item.children) { + const isChildActive = item.children.some(child => child.path && isActive(child.path)); + + return ( + + + + {item.label} + + + + + {item.children.map((child) => { + const childActive = child.path && location.pathname === child.path; + return ( + + + + {child.label} + + + ); + })} + + + ); + } + + const active = item.path && ( + item.path === PATHS.ROOT + ? location.pathname === PATHS.ROOT + : isActive(item.path) + ); return ( @@ -75,19 +117,11 @@ export const Navbar = () => { ); })} -
+
- - + @@ -100,7 +134,7 @@ export const Navbar = () => { - +

@@ -117,9 +151,11 @@ export const Navbar = () => { - navigate(PATHS.PROFILE)} className="cursor-pointer flex items-center gap-2 py-2"> - - Mój Profil + + + + Mój Profil + @@ -128,15 +164,14 @@ export const Navbar = () => { logoutUser()} - className="cursor-pointer flex items-center gap-2 py-2 text-slate-600 hover:text-slate-900" + className="cursor-pointer flex items-center gap-2.5 p-2 rounded-md text-slate-600 hover:text-slate-900" > - - {isLoggingOut ? 'Wylogowywanie...' : 'Wyloguj'} + + {isLoggingOut ? 'Wylogowywanie...' : 'Wyloguj'} -

diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 2eb55130..ae9fda57 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -19,6 +19,8 @@ const badgeVariants = cva( ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", link: "text-primary underline-offset-4 hover:underline", + green: "bg-green-100 text-green-800 focus-visible:ring-green-500/20 dark:bg-green-500/20 dark:focus-visible:ring-green-500/40 [a]:hover:bg-green-200", + red: "bg-red-100 text-red-800 focus-visible:ring-red-500/20 dark:bg-red-500/20 dark:focus-visible:ring-red-500/40 [a]:hover:bg-red-200", }, }, defaultVariants: { diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 3939db4a..bfe7a233 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" -const buttonVariants = cva( +export const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 00000000..d20b75ec --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 527af74c..f26e17ab 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -59,7 +59,7 @@ function DialogContent({ = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +const FormItemContext = React.createContext<{ id: string }>({} as { id: string }) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +