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/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/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/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/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 c8a05c05..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 @@ -10,11 +10,16 @@ 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; @@ -30,6 +35,7 @@ class UserController { private final UserInvitationService invitationService; private final UserService userService; private final AssignmentManagementService assignmentManagementService; + private final QualificationService qualificationService; @GetMapping("/users") @PreAuthorize("isAuthenticated()") @@ -95,4 +101,40 @@ public ResponseEntity> searchUsers( 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/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/service/user/UserService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java index f0377e36..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 @@ -11,21 +11,33 @@ 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 @@ -33,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 @@ -111,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/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 index 4b1ce01f..acf56331 100644 --- 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 @@ -34,7 +34,16 @@ class ProjectPdfGeneratorTest { @BeforeEach void setUp() { - UserResponse manager = new UserResponse(UUID.randomUUID(),"john.doe@example.com", "John", "Doe", null,null, null, List.of() + 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); } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 0cdadf57..937b01ed 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -10,9 +10,15 @@ export const ENDPOINTS = { DETAIL: (id: string) => `/users/${id}`, RESEND_INVITATION: '/users/invitation', WORKLOAD: (id?: string) => `/users/${id}/workload`, - SEARCH_USERS: '/users/search' + 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', diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 221fe9bb..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], }); diff --git a/frontend/src/features/notification/notification.utils.ts b/frontend/src/features/notification/notification.utils.ts index 4306e76d..f2f4e0bf 100644 --- a/frontend/src/features/notification/notification.utils.ts +++ b/frontend/src/features/notification/notification.utils.ts @@ -7,7 +7,6 @@ export const getNotificationUrl = (type: NotificationType, referenceId: string): return `${PATHS.PROJECT_REQUESTS}?${QUERY_PARAMS.REQUEST_ID}=${referenceId}`; case 'ASSIGNMENT_ACCEPTED': - return PATHS.PROJECT(referenceId); case 'ASSIGNMENT_REJECTED': return PATHS.PROJECT(referenceId); @@ -19,7 +18,7 @@ export const getNotificationUrl = (type: NotificationType, referenceId: string): return PATHS.PROFILE; case 'SYSTEM_NEW_EMPLOYEE': - return PATHS.PROFILE; // TODO: Powinno przenosic na profil pracownika, ale nie ma obecnie takiej mozliwosci + return PATHS.EMPLOYEE_DETAILS(referenceId); default: return PATHS.ROOT; diff --git a/frontend/src/features/profile/components/ManagedProjectsCard.tsx b/frontend/src/features/profile/components/ManagedProjectsCard.tsx new file mode 100644 index 00000000..9acfe185 --- /dev/null +++ b/frontend/src/features/profile/components/ManagedProjectsCard.tsx @@ -0,0 +1,192 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Briefcase, CalendarDays, FolderOpen } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { PATHS } from '@/routes/paths'; +import { PROJECT_GROUP_TYPE_LABELS } from '@/features/project_group/project_group.types'; +import { useUserManagedProjectsQuery } from '@/features/user-management/user-management.hooks'; +import { SectionHeader } from './SectionHeader'; + +interface ManagedProjectsCardProps { + userId: string; +} + +type StatusFilter = 'all' | 'active' | 'inactive'; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString('pl-PL', { day: '2-digit', month: 'short', year: 'numeric' }); + +const computeProgress = (startIso: string, endIso: string) => { + const start = new Date(startIso).getTime(); + const end = new Date(endIso).getTime(); + const now = Date.now(); + if (now <= start) return 0; + if (now >= end) return 100; + return Math.round(((now - start) / (end - start)) * 100); +}; + +export const ManagedProjectsCard = ({ userId }: ManagedProjectsCardProps) => { + const [isOpen, setIsOpen] = useState(true); + const [statusFilter, setStatusFilter] = useState('all'); + const { data: projects, isLoading, isError } = useUserManagedProjectsQuery(userId); + + const total = projects?.length ?? 0; + const activeCount = useMemo( + () => (projects ?? []).filter((p) => p.isActive).length, + [projects] + ); + const inactiveCount = total - activeCount; + + const filtered = useMemo(() => { + if (!projects) return []; + if (statusFilter === 'all') return projects; + return projects.filter((p) => (statusFilter === 'active' ? p.isActive : !p.isActive)); + }, [projects, statusFilter]); + + return ( + + + + {total} + + } + /> + + + + {isLoading && ( +
+ + +
+ )} + + {isError && ( +

+ Nie udało się pobrać listy projektów. +

+ )} + + {!isLoading && !isError && total === 0 && ( +
+ +

Brak projektów

+

+ Użytkownik nie prowadzi obecnie żadnego projektu. +

+
+ )} + + {!isLoading && !isError && total > 0 && ( + <> +
+
+ + Aktywne {activeCount} + + + Nieaktywne {inactiveCount} + +
+ +
+ + {filtered.length === 0 ? ( +

+ Brak projektów dla wybranego filtra. +

+ ) : ( +
    + {filtered.map((p) => { + const progress = computeProgress(p.startDate, p.endDate); + return ( +
  • +
    +
    + + {p.title} + + {p.description && ( +

    + {p.description} +

    + )} +
    + + {p.isActive ? 'Aktywny' : 'Nieaktywny'} + +
    + +
    + + + {formatDate(p.startDate)} – {formatDate(p.endDate)} + + {p.group && ( + + {PROJECT_GROUP_TYPE_LABELS[p.group.groupType]}: {p.group.name} + + )} +
    + +
    +
    +
    +
    + + {progress}% + +
    +
  • + ); + })} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/features/profile/components/OwnedGroupsCard.tsx b/frontend/src/features/profile/components/OwnedGroupsCard.tsx new file mode 100644 index 00000000..6f81ca99 --- /dev/null +++ b/frontend/src/features/profile/components/OwnedGroupsCard.tsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, FolderTree, Layers, Wallet } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { PATHS } from '@/routes/paths'; +import { + PROJECT_GROUP_TYPE_LABELS, + ProjectGroupType, +} from '@/features/project_group/project_group.types'; +import { useUserOwnedGroupsQuery } from '@/features/user-management/user-management.hooks'; +import { SectionHeader } from './SectionHeader'; + +interface OwnedGroupsCardProps { + userId: string; +} + +const TYPE_ICON = { + [ProjectGroupType.WALLET]: Wallet, + [ProjectGroupType.PROGRAM]: Layers, +}; + +const TYPE_TINT = { + [ProjectGroupType.WALLET]: 'bg-amber-50 text-amber-600', + [ProjectGroupType.PROGRAM]: 'bg-indigo-50 text-indigo-600', +}; + +export const OwnedGroupsCard = ({ userId }: OwnedGroupsCardProps) => { + const [isOpen, setIsOpen] = useState(true); + const { data: groups, isLoading, isError } = useUserOwnedGroupsQuery(userId); + + const total = groups?.length ?? 0; + const walletCount = useMemo( + () => (groups ?? []).filter((g) => g.groupType === ProjectGroupType.WALLET).length, + [groups] + ); + const programCount = total - walletCount; + + return ( + + + 0 ? ( + + {total} + + ) : undefined + } + /> + + + + {isLoading && ( +
+ + +
+ )} + + {isError && ( +

+ Nie udało się pobrać listy grup. +

+ )} + + {!isLoading && !isError && total === 0 && ( +
+ +

Brak grup projektów

+

+ Użytkownik nie uczestniczy w żadnym portfelu ani programie. +

+
+ )} + + {!isLoading && !isError && total > 0 && ( + <> +
+ + + Portfele + + {walletCount} + + + + + Programy + + {programCount} + + +
+ +
    + {groups!.map((g) => { + const Icon = TYPE_ICON[g.groupType]; + return ( +
  • + +
    + +
    +
    +
    + + {g.name} + + {g.isOwner && ( + + Właściciel + + )} +
    +
    + {PROJECT_GROUP_TYPE_LABELS[g.groupType]} + · + + {g.projectCount} {g.projectCount === 1 ? 'projekt' : 'projektów'} + + {g.activeProjectCount > 0 && ( + <> + · + + {g.activeProjectCount} aktywnych + + + )} +
    + {g.description && ( +

    + {g.description} +

    + )} +
    + + +
  • + ); + })} +
+ + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/features/profile/components/ProfileHeaderCard.tsx b/frontend/src/features/profile/components/ProfileHeaderCard.tsx index b4eea31e..5ff175f3 100644 --- a/frontend/src/features/profile/components/ProfileHeaderCard.tsx +++ b/frontend/src/features/profile/components/ProfileHeaderCard.tsx @@ -1,83 +1,142 @@ import { useState } from 'react'; -import { KeyRound, Mail, ShieldCheck } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { KeyRound, Mail, ShieldCheck, UserCog } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardFooter } from '@/components/ui/card'; -import { RoleBadge } from '@/features/user-management/components/RoleBadge'; +import { ROLE_LABELS } from '@/features/user-management/components/RoleBadge'; import type { UserInfo } from '@/features/auth/auth.types'; +import type { SimpleUserResponse } from '@/features/user-management/user-management.types'; +import { PATHS } from '@/routes/paths'; +import { cn } from '@/lib/utils'; import { ChangePasswordModal } from './ChangePasswordModal'; interface ProfileHeaderCardProps { user: UserInfo; + readOnly?: boolean; + supervisor?: SimpleUserResponse | null; } const getInitials = (firstName: string, lastName: string) => `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || 'U'; -export const ProfileHeaderCard = ({ user }: ProfileHeaderCardProps) => { +const supervisorFullName = (s: SimpleUserResponse) => + [s.name, s.surname].filter(Boolean).join(' ') || s.email; + +interface StatItemProps { + icon: LucideIcon; + iconClassName: string; + label: string; + children: React.ReactNode; +} + +const StatItem = ({ icon: Icon, iconClassName, label, children }: StatItemProps) => ( +
+
+ +
+
+
+ {label} +
+
{children}
+
+
+); + +const Divider = () =>
; + +export const ProfileHeaderCard = ({ user, readOnly = false, supervisor = null }: ProfileHeaderCardProps) => { const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || 'Użytkownik'; + const showSupervisor = readOnly && !!supervisor; + return ( <> -
+
+
+
- - + + {getInitials(user.firstName, user.lastName)} -
-
-

{fullName}

-

+

+

{fullName}

+ {!readOnly && ( +

Zarządzaj swoimi danymi konta i ustawieniami bezpieczeństwa.

-
+ )}
- - -
-
- -
-
-
E-mail
-
{user.email}
-
-
-
-
- -
-
-
Rola
-
- -
-
-
-
- -
+ + + {user.email} + + + + + + {ROLE_LABELS[user.role]} + + + {showSupervisor && ( + <> + + + + {supervisorFullName(supervisor!)} + + + + )} + + {!readOnly && ( + <> + +
+ +
+ + )}
- + {!readOnly && ( + + )} ); }; diff --git a/frontend/src/features/profile/components/ProfileSections.tsx b/frontend/src/features/profile/components/ProfileSections.tsx new file mode 100644 index 00000000..f1e18033 --- /dev/null +++ b/frontend/src/features/profile/components/ProfileSections.tsx @@ -0,0 +1,33 @@ +import { UserRole } from '@/features/auth/auth.types'; +import { QualificationsCard } from '@/features/qualifications'; +import { getProfileSections } from '../profile.config'; +import { ManagedProjectsCard } from './ManagedProjectsCard'; +import { OwnedGroupsCard } from './OwnedGroupsCard'; +import { ProjectMembershipCard } from './ProjectMembershipCard'; +import { SubordinatesCard } from './SubordinatesCard'; +import { WorkloadCard } from './WorkloadCard'; + +interface ProfileSectionsProps { + userId: string; + role: UserRole; + editable?: boolean; +} + +export const ProfileSections = ({ userId, role, editable = false }: ProfileSectionsProps) => { + const sections = getProfileSections(role); + + return ( + <> + {sections.qualifications && ( + editable + ? + : + )} + {sections.memberships && } + {sections.workload && } + {sections.subordinates && } + {sections.projects && } + {sections.ownedGroups && } + + ); +}; diff --git a/frontend/src/features/profile/components/ProjectMembershipCard.tsx b/frontend/src/features/profile/components/ProjectMembershipCard.tsx new file mode 100644 index 00000000..ef8b6694 --- /dev/null +++ b/frontend/src/features/profile/components/ProjectMembershipCard.tsx @@ -0,0 +1,191 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { CalendarDays, FolderOpen, Users2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { PATHS } from '@/routes/paths'; +import { PROJECT_GROUP_TYPE_LABELS } from '@/features/project_group/project_group.types'; +import { useUserProjectMembershipsQuery } from '@/features/user-management/user-management.hooks'; +import { SectionHeader } from './SectionHeader'; + +interface ProjectMembershipCardProps { + userId: string; +} + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString('pl-PL', { day: '2-digit', month: 'short', year: 'numeric' }); + +const computeProgress = (startIso: string, endIso: string) => { + const start = new Date(startIso).getTime(); + const end = new Date(endIso).getTime(); + const now = Date.now(); + if (now <= start) return 0; + if (now >= end) return 100; + return Math.round(((now - start) / (end - start)) * 100); +}; + +export const ProjectMembershipCard = ({ userId }: ProjectMembershipCardProps) => { + const [isOpen, setIsOpen] = useState(true); + const { data: memberships, isLoading, isError } = useUserProjectMembershipsQuery(userId); + + const total = memberships?.length ?? 0; + const activeCount = useMemo( + () => (memberships ?? []).filter((m) => m.isActive).length, + [memberships] + ); + + return ( + + + 0 ? ( + + {total} + + ) : undefined + } + /> + + + + {isLoading && ( +
+ + +
+ )} + + {isError && ( +

+ Nie udało się pobrać listy projektów. +

+ )} + + {!isLoading && !isError && total === 0 && ( +
+ +

Brak projektów

+

+ Użytkownik nie ma obecnie żadnych zaakceptowanych przydziałów. +

+
+ )} + + {!isLoading && !isError && total > 0 && ( + <> +
+ + Aktywne {activeCount} + + + Zakończone {total - activeCount} + +
+ +
    + {memberships!.map((m) => { + const progress = computeProgress(m.startDate, m.endDate); + return ( +
  • +
    +
    + + {m.title} + + {m.description && ( +

    + {m.description} +

    + )} +
    + + {m.isActive ? 'Aktywny' : 'Nieaktywny'} + +
    + +
    + + + {formatDate(m.startDate)} – {formatDate(m.endDate)} + + {m.group && ( + + {PROJECT_GROUP_TYPE_LABELS[m.group.groupType]}: {m.group.name} + + )} +
    + + {m.roles.length > 0 && ( +
    +
    + {m.roles.length === 1 ? 'Rola' : 'Role'} +
    +
      + {m.roles.map((r, idx) => ( +
    • + {r.roleName} + · + + {r.utilizationPercentage}% + + · + + {formatDate(r.startDate)} – {formatDate(r.endDate)} + +
    • + ))} +
    +
    + )} + +
    +
    +
    +
    + + {progress}% + +
    +
  • + ); + })} +
+ + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/features/profile/components/SectionHeader.tsx b/frontend/src/features/profile/components/SectionHeader.tsx new file mode 100644 index 00000000..bfa440fb --- /dev/null +++ b/frontend/src/features/profile/components/SectionHeader.tsx @@ -0,0 +1,55 @@ +import type { LucideIcon } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; +import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; + +export type SectionAccent = 'blue' | 'emerald' | 'purple' | 'amber' | 'rose'; + +const ACCENT: Record = { + blue: { bg: 'bg-gradient-to-br from-blue-500 to-indigo-600', text: 'text-white', ring: 'ring-blue-100' }, + emerald: { bg: 'bg-gradient-to-br from-emerald-500 to-teal-600', text: 'text-white', ring: 'ring-emerald-100' }, + purple: { bg: 'bg-gradient-to-br from-purple-500 to-fuchsia-600', text: 'text-white', ring: 'ring-purple-100' }, + amber: { bg: 'bg-gradient-to-br from-amber-500 to-orange-600', text: 'text-white', ring: 'ring-amber-100' }, + rose: { bg: 'bg-gradient-to-br from-rose-500 to-pink-600', text: 'text-white', ring: 'ring-rose-100' }, +}; + +interface SectionHeaderProps { + icon: LucideIcon; + accent?: SectionAccent; + title: string; + description: string; + isOpen: boolean; + badge?: React.ReactNode; +} + +export const SectionHeader = ({ + icon: Icon, + accent = 'blue', + title, + description, + isOpen, + badge, +}: SectionHeaderProps) => { + const a = ACCENT[accent]; + + return ( + + +
+ +
+
+
+ {title} + {badge} + +
+ {description} +
+
+
+ ); +}; diff --git a/frontend/src/features/profile/components/SubordinatesCard.tsx b/frontend/src/features/profile/components/SubordinatesCard.tsx new file mode 100644 index 00000000..2620bf7c --- /dev/null +++ b/frontend/src/features/profile/components/SubordinatesCard.tsx @@ -0,0 +1,147 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronRight, Search, Users } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PATHS } from '@/routes/paths'; +import { UserRole } from '@/features/auth/auth.types'; +import { ROLE_LABELS, RoleBadge } from '@/features/user-management/components/RoleBadge'; +import { useUserSubordinatesQuery } from '@/features/user-management/user-management.hooks'; +import { SectionHeader } from './SectionHeader'; + +interface SubordinatesCardProps { + userId: string; +} + +const getInitials = (name: string | null, surname: string | null) => + `${(name ?? '?').charAt(0)}${(surname ?? '?').charAt(0)}`.toUpperCase(); + +export const SubordinatesCard = ({ userId }: SubordinatesCardProps) => { + const [isOpen, setIsOpen] = useState(true); + const [search, setSearch] = useState(''); + const { data: subordinates, isLoading, isError } = useUserSubordinatesQuery(userId); + const navigate = useNavigate(); + + const total = subordinates?.length ?? 0; + + const roleCounts = useMemo(() => { + const acc: Partial> = {}; + (subordinates ?? []).forEach((s) => { + acc[s.role] = (acc[s.role] ?? 0) + 1; + }); + return acc; + }, [subordinates]); + + const filtered = useMemo(() => { + if (!subordinates) return []; + const q = search.trim().toLowerCase(); + if (!q) return subordinates; + return subordinates.filter((s) => { + const name = `${s.name ?? ''} ${s.surname ?? ''}`.toLowerCase(); + return name.includes(q) || s.email.toLowerCase().includes(q); + }); + }, [subordinates, search]); + + return ( + + + + {total} + + } + /> + + + + {isLoading && ( +
+ + + +
+ )} + + {isError && ( +

+ Nie udało się pobrać listy podwładnych. +

+ )} + + {!isLoading && !isError && total === 0 && ( +
+ +

Brak podwładnych

+

+ Nikt jeszcze nie został przypisany do tego przełożonego. +

+
+ )} + + {!isLoading && !isError && total > 0 && ( + <> +
+ {Object.entries(roleCounts).map(([role, count]) => ( + + {ROLE_LABELS[role as UserRole]} + {count} + + ))} +
+ +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {filtered.length === 0 ? ( +

+ Brak wyników dla „{search}". +

+ ) : ( +
    + {filtered.map((sub) => ( +
  • navigate(PATHS.EMPLOYEE_DETAILS(sub.id))} + className="group flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-all hover:border-blue-200 hover:bg-blue-50/40 hover:shadow-sm" + > +
    + {getInitials(sub.name, sub.surname)} +
    +
    +
    + {sub.name} {sub.surname} +
    +
    {sub.email}
    +
    + +
    +
    + +
  • + ))} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/features/profile/components/WorkloadCard.tsx b/frontend/src/features/profile/components/WorkloadCard.tsx new file mode 100644 index 00000000..fe3d549a --- /dev/null +++ b/frontend/src/features/profile/components/WorkloadCard.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react'; +import { Activity } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useUserWorkload } from '@/features/user-management/user-management.hooks'; +import { EmployeeWorkloadChart } from '@/features/employee-assignments/components/EmployeeWorkloadChart'; +import { SectionHeader } from './SectionHeader'; + +interface WorkloadCardProps { + userId: string; +} + +export const WorkloadCard = ({ userId }: WorkloadCardProps) => { + const [isOpen, setIsOpen] = useState(true); + const { userWorkload, isLoadingWorkload, isErrorWorkload } = useUserWorkload(userId); + + const intervals = useMemo(() => userWorkload ?? [], [userWorkload]); + const hasData = intervals.length > 0; + + const peak = useMemo( + () => intervals.reduce((max, i) => Math.max(max, i.percentage), 0), + [intervals] + ); + const isOverloaded = peak > 100; + + return ( + + + + Szczyt {peak}% + + ) : undefined + } + /> + + + + {isLoadingWorkload && } + + {isErrorWorkload && ( +

+ Nie udało się pobrać danych o obciążeniu. +

+ )} + + {!isLoadingWorkload && !isErrorWorkload && !hasData && ( +
+ +

Brak aktywnych przydziałów

+

+ Użytkownik nie jest obecnie przypisany do żadnego projektu. +

+
+ )} + + {!isLoadingWorkload && !isErrorWorkload && hasData && ( +
+ +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/features/profile/index.ts b/frontend/src/features/profile/index.ts index bd68b52e..9831fe80 100644 --- a/frontend/src/features/profile/index.ts +++ b/frontend/src/features/profile/index.ts @@ -1,2 +1,9 @@ export { ProfileHeaderCard } from './components/ProfileHeaderCard'; export { ChangePasswordModal } from './components/ChangePasswordModal'; +export { SubordinatesCard } from './components/SubordinatesCard'; +export { ManagedProjectsCard } from './components/ManagedProjectsCard'; +export { OwnedGroupsCard } from './components/OwnedGroupsCard'; +export { ProjectMembershipCard } from './components/ProjectMembershipCard'; +export { WorkloadCard } from './components/WorkloadCard'; +export { ProfileSections } from './components/ProfileSections'; +export { hasProfileSections, getProfileSections } from './profile.config'; diff --git a/frontend/src/features/profile/profile.config.ts b/frontend/src/features/profile/profile.config.ts new file mode 100644 index 00000000..783e6cf9 --- /dev/null +++ b/frontend/src/features/profile/profile.config.ts @@ -0,0 +1,22 @@ +import { UserRole } from '@/features/auth/auth.types'; + +export interface ProfileSectionFlags { + qualifications: boolean; + memberships: boolean; + workload: boolean; + subordinates: boolean; + projects: boolean; + ownedGroups: boolean; +} + +export const getProfileSections = (role: UserRole): ProfileSectionFlags => ({ + qualifications: role === UserRole.COMMON, + memberships: role === UserRole.COMMON, + workload: role === UserRole.COMMON, + subordinates: role === UserRole.LINEAR_MANAGER || role === UserRole.AUTHORITY, + projects: role === UserRole.PROJECT_MANAGER || role === UserRole.AUTHORITY, + ownedGroups: role === UserRole.PROJECT_MANAGER || role === UserRole.AUTHORITY, +}); + +export const hasProfileSections = (role: UserRole): boolean => + Object.values(getProfileSections(role)).some(Boolean); diff --git a/frontend/src/features/project/components/ProjectTimeline.tsx b/frontend/src/features/project/components/ProjectTimeline.tsx index 0ffdfce4..c0169ca4 100644 --- a/frontend/src/features/project/components/ProjectTimeline.tsx +++ b/frontend/src/features/project/components/ProjectTimeline.tsx @@ -124,8 +124,7 @@ export const ProjectTimeline = ({ project, children }: ProjectTimelineProps) => + {onDelete && ( + + )} ); diff --git a/frontend/src/features/qualifications/components/QualificationsCard.tsx b/frontend/src/features/qualifications/components/QualificationsCard.tsx index 53019d38..9e215a1d 100644 --- a/frontend/src/features/qualifications/components/QualificationsCard.tsx +++ b/frontend/src/features/qualifications/components/QualificationsCard.tsx @@ -1,57 +1,59 @@ import { useState } from 'react'; -import { Award, ChevronDown, Plus } from 'lucide-react'; +import { Award, Plus } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Skeleton } from '@/components/ui/skeleton'; -import { cn } from '@/lib/utils'; -import { useMyQualificationsQuery } from '../qualifications.hooks'; +import { SectionHeader } from '@/features/profile/components/SectionHeader'; +import { useMyQualificationsQuery, useUserQualificationsQuery } from '../qualifications.hooks'; import type { QualificationStatus } from '../qualifications.types'; import { QualificationsList } from './QualificationsList'; import { QualificationStats } from './QualificationStats'; import { AddQualificationModal } from './AddQualificationModal'; -export const QualificationsCard = () => { +interface QualificationsCardProps { + userId?: string; + readOnly?: boolean; +} + +export const QualificationsCard = ({ userId, readOnly = false }: QualificationsCardProps) => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isOpen, setIsOpen] = useState(true); const [activeFilter, setActiveFilter] = useState(null); - const { data: qualifications, isLoading, isError } = useMyQualificationsQuery(); + + const myQuery = useMyQualificationsQuery(); + const otherQuery = useUserQualificationsQuery(userId); + const { data: qualifications, isLoading, isError } = userId ? otherQuery : myQuery; const filteredQualifications = activeFilter ? (qualifications ?? []).filter((q) => q.status === activeFilter) : (qualifications ?? []); + const title = readOnly ? 'Kompetencje' : 'Moje kompetencje'; + const description = readOnly + ? 'Zatwierdzone kompetencje pracownika.' + : 'Zatwierdzone kompetencje są widoczne dla kierowników przy przydziale do projektów.'; + + const total = qualifications?.length ?? 0; + return ( - - -
- -
-
-
- Moje kompetencje - -
- - Zatwierdzone kompetencje są widoczne dla kierowników przy przydziale do projektów. - -
-
-
+ 0 ? ( + + {total} + + ) : undefined + } + /> @@ -63,15 +65,17 @@ export const QualificationsCard = () => { onFilterChange={setActiveFilter} /> )} -
- -
+ {!readOnly && ( +
+ +
+ )}
{isLoading && ( @@ -96,17 +100,19 @@ export const QualificationsCard = () => { {!isLoading && !isError && ( <> - + )}
- + {!readOnly && ( + + )} ); diff --git a/frontend/src/features/qualifications/components/QualificationsList.tsx b/frontend/src/features/qualifications/components/QualificationsList.tsx index 914d6537..6e5c64fd 100644 --- a/frontend/src/features/qualifications/components/QualificationsList.tsx +++ b/frontend/src/features/qualifications/components/QualificationsList.tsx @@ -5,9 +5,10 @@ import { QualificationItem } from './QualificationItem'; interface QualificationsListProps { qualifications: QualificationResponse[]; + readOnly?: boolean; } -export const QualificationsList = ({ qualifications }: QualificationsListProps) => { +export const QualificationsList = ({ qualifications, readOnly = false }: QualificationsListProps) => { const deleteMutation = useDeleteQualificationMutation(); if (qualifications.length === 0) { @@ -15,9 +16,11 @@ export const QualificationsList = ({ qualifications }: QualificationsListProps)

Brak kompetencji

-

- Dodaj swoją pierwszą umiejętność -

+ {!readOnly && ( +

+ Dodaj swoją pierwszą umiejętność +

+ )}
); } @@ -28,7 +31,7 @@ export const QualificationsList = ({ qualifications }: QualificationsListProps) deleteMutation.mutate(id)} + onDelete={readOnly ? undefined : (id) => deleteMutation.mutate(id)} isDeleting={deleteMutation.isPending && deleteMutation.variables === qualification.id} /> ))} diff --git a/frontend/src/features/qualifications/qualifications.hooks.ts b/frontend/src/features/qualifications/qualifications.hooks.ts index 34688ea4..ede2d8a2 100644 --- a/frontend/src/features/qualifications/qualifications.hooks.ts +++ b/frontend/src/features/qualifications/qualifications.hooks.ts @@ -9,6 +9,13 @@ export const useMyQualificationsQuery = () => queryFn: qualificationsService.getMyQualifications, }); +export const useUserQualificationsQuery = (userId?: string) => + useQuery({ + queryKey: queryKeys.qualifications.byUser(userId ?? '').queryKey, + queryFn: () => qualificationsService.getUserQualifications(userId as string), + enabled: !!userId, + }); + export const useAddQualificationsMutation = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/features/qualifications/qualifications.service.ts b/frontend/src/features/qualifications/qualifications.service.ts index c7f6f7df..415f3040 100644 --- a/frontend/src/features/qualifications/qualifications.service.ts +++ b/frontend/src/features/qualifications/qualifications.service.ts @@ -8,6 +8,11 @@ export const qualificationsService = { return res.data; }, + getUserQualifications: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.QUALIFICATIONS(userId)); + return res.data; + }, + addQualifications: async (data: AddQualificationRequest): Promise => { const res = await api.post(ENDPOINTS.ME.QUALIFICATIONS, data); return res.data; diff --git a/frontend/src/features/user-management/components/RoleBadge.tsx b/frontend/src/features/user-management/components/RoleBadge.tsx index 49c93fa9..ce2ca5ca 100644 --- a/frontend/src/features/user-management/components/RoleBadge.tsx +++ b/frontend/src/features/user-management/components/RoleBadge.tsx @@ -1,13 +1,51 @@ -import type { UserRole } from '@/features/auth/auth.types'; - -const labels: Record = { - COMMON: 'Employee', - AUTHORITY: 'Authority', - LINEAR_MANAGER: 'Linear Manager', - PROJECT_MANAGER: 'Project Manager', - ADMINISTRATOR: 'Administrator', +import { Briefcase, Crown, ShieldCheck, User, Users } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { UserRole } from '@/features/auth/auth.types'; + +const config: Record = { + COMMON: { + label: 'Pracownik', + className: 'bg-slate-100 text-slate-700 border-slate-200', + Icon: User, + }, + AUTHORITY: { + label: 'Władze Wydziału', + className: 'bg-purple-100 text-purple-700 border-purple-200', + Icon: Crown, + }, + LINEAR_MANAGER: { + label: 'Kierownik Liniowy', + className: 'bg-blue-100 text-blue-700 border-blue-200', + Icon: Users, + }, + PROJECT_MANAGER: { + label: 'Kierownik Projektu', + className: 'bg-emerald-100 text-emerald-700 border-emerald-200', + Icon: Briefcase, + }, + ADMINISTRATOR: { + label: 'Administrator', + className: 'bg-amber-100 text-amber-700 border-amber-200', + Icon: ShieldCheck, + }, +}; + +export const ROLE_LABELS: Record = { + COMMON: config.COMMON.label, + AUTHORITY: config.AUTHORITY.label, + LINEAR_MANAGER: config.LINEAR_MANAGER.label, + PROJECT_MANAGER: config.PROJECT_MANAGER.label, + ADMINISTRATOR: config.ADMINISTRATOR.label, }; -export const RoleBadge = ({ role }: { role: UserRole }) => ( - {labels[role]} -); +export const RoleBadge = ({ role }: { role: UserRole }) => { + const { label, className, Icon } = config[role]; + + return ( + + + {label} + + ); +}; diff --git a/frontend/src/features/user-management/query-keys.ts b/frontend/src/features/user-management/query-keys.ts index 0c59da7a..369fb06f 100644 --- a/frontend/src/features/user-management/query-keys.ts +++ b/frontend/src/features/user-management/query-keys.ts @@ -4,5 +4,11 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; export const usersKeys = createQueryKeys('users', { list: (page: number, size: number, filters: UserListParams) => [page, size, filters], search: (searchTerm: string, role?: UserSearchableRole) => [searchTerm, role], - workload: (userId?: string) => [userId] + workload: (userId?: string) => [userId], + me: null, + detail: (userId: string) => [userId], + subordinates: (userId: string) => ['subordinates', userId], + projects: (userId: string) => ['projects', userId], + memberships: (userId: string) => ['memberships', userId], + groups: (userId: string) => ['groups', userId], }) diff --git a/frontend/src/features/user-management/user-management.hooks.ts b/frontend/src/features/user-management/user-management.hooks.ts index 32d16200..de2bbe00 100644 --- a/frontend/src/features/user-management/user-management.hooks.ts +++ b/frontend/src/features/user-management/user-management.hooks.ts @@ -67,6 +67,53 @@ export const useUserWorkload = (userId?: string) => { }; } +export const useMyProfileQuery = () => { + return useQuery({ + queryKey: usersKeys.me.queryKey, + queryFn: () => userManagementService.getMyProfile(), + }); +}; + +export const useUserQuery = (userId?: string) => { + return useQuery({ + queryKey: usersKeys.detail(userId ?? '').queryKey, + queryFn: () => userManagementService.getUser(userId as string), + enabled: !!userId, + }); +}; + +export const useUserSubordinatesQuery = (userId?: string) => { + return useQuery({ + queryKey: usersKeys.subordinates(userId ?? '').queryKey, + queryFn: () => userManagementService.getSubordinates(userId as string), + enabled: !!userId, + }); +}; + +export const useUserManagedProjectsQuery = (userId?: string) => { + return useQuery({ + queryKey: usersKeys.projects(userId ?? '').queryKey, + queryFn: () => userManagementService.getManagedProjects(userId as string), + enabled: !!userId, + }); +}; + +export const useUserProjectMembershipsQuery = (userId?: string) => { + return useQuery({ + queryKey: usersKeys.memberships(userId ?? '').queryKey, + queryFn: () => userManagementService.getProjectMemberships(userId as string), + enabled: !!userId, + }); +}; + +export const useUserOwnedGroupsQuery = (userId?: string) => { + return useQuery({ + queryKey: usersKeys.groups(userId ?? '').queryKey, + queryFn: () => userManagementService.getOwnedGroups(userId as string), + enabled: !!userId, + }); +}; + export const usePotentialSupervisors = (searchTerm: string) => { const linearManagersQuery = useQuery({ queryKey: usersKeys.search(searchTerm, UserSearchableRole.LINEAR_MANAGER).queryKey, diff --git a/frontend/src/features/user-management/user-management.service.ts b/frontend/src/features/user-management/user-management.service.ts index df2e3222..d987b5ef 100644 --- a/frontend/src/features/user-management/user-management.service.ts +++ b/frontend/src/features/user-management/user-management.service.ts @@ -1,7 +1,8 @@ import api from '@/api/client'; import { ENDPOINTS } from '@/api/endpoints'; import type { PagedResponse, PaginationParams } from '@/api/api.types'; -import type {InviteUserRequest, SimpleUserResponse, UserListParams, UserResponse, UserSearchableRole, UserWorkloadResponse} from './user-management.types'; +import type { ProjectDetailsResponse } from '@/features/project/project.types'; +import type {InviteUserRequest, OwnedGroupResponse, SimpleUserResponse, UserListParams, UserProjectMembershipResponse, UserResponse, UserSearchableRole, UserWorkloadResponse} from './user-management.types'; export const userManagementService = { getUsers: async (params: PaginationParams & UserListParams = {}): Promise> => { @@ -31,5 +32,35 @@ export const userManagementService = { getUserWorkload: async (userId?: string): Promise => { const res = await api.get(ENDPOINTS.USERS.WORKLOAD(userId)); return res.data; - } + }, + + getUser: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.DETAIL(userId)); + return res.data; + }, + + getMyProfile: async (): Promise => { + const res = await api.get(ENDPOINTS.ME.PROFILE); + return res.data; + }, + + getSubordinates: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.SUBORDINATES(userId)); + return res.data; + }, + + getManagedProjects: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.PROJECTS(userId)); + return res.data; + }, + + getProjectMemberships: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.MEMBERSHIPS(userId)); + return res.data; + }, + + getOwnedGroups: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.USERS.GROUPS(userId)); + return res.data; + }, }; diff --git a/frontend/src/features/user-management/user-management.types.ts b/frontend/src/features/user-management/user-management.types.ts index c558602a..8395c3b6 100644 --- a/frontend/src/features/user-management/user-management.types.ts +++ b/frontend/src/features/user-management/user-management.types.ts @@ -33,6 +33,7 @@ export interface UserResponse { role: UserRole; status: UserStatus; supervisorEmail: string | null; + supervisor: SimpleUserResponse | null; } export interface SimpleUserResponse { @@ -56,4 +57,32 @@ export interface UserListParams { export interface UserWorkloadResponse { workload: ChartInterval[]; +} + +export interface UserProjectRoleResponse { + roleName: string; + startDate: string; + endDate: string; + utilizationPercentage: number; +} + +export interface OwnedGroupResponse { + id: string; + name: string; + description: string; + groupType: import('@/features/project_group/project_group.types').ProjectGroupType; + projectCount: number; + activeProjectCount: number; + isOwner: boolean; +} + +export interface UserProjectMembershipResponse { + id: string; + title: string; + description: string; + startDate: string; + endDate: string; + isActive: boolean; + group: import('@/features/project_group/project_group.types').GroupBasicResponse | null; + roles: UserProjectRoleResponse[]; } \ No newline at end of file diff --git a/frontend/src/pages/AdminUserDetailsPage.tsx b/frontend/src/pages/AdminUserDetailsPage.tsx index 0c4e7081..29837f34 100644 --- a/frontend/src/pages/AdminUserDetailsPage.tsx +++ b/frontend/src/pages/AdminUserDetailsPage.tsx @@ -1,15 +1,3 @@ -import { useParams } from 'react-router-dom'; -import { ROUTE_PARAMS } from '@/routes/paths'; +import { EmployeeDetailsPage } from './EmployeeDetailsPage'; -export const AdminUserDetailsPage = () => { - const { [ROUTE_PARAMS.USER_ID]: userId } = useParams(); - - return ( -
-
-

Profil użytkownika

-

TODO: szczegóły użytkownika {userId}

-
-
- ); -}; +export const AdminUserDetailsPage = () => ; diff --git a/frontend/src/pages/EmployeeDetailsPage.tsx b/frontend/src/pages/EmployeeDetailsPage.tsx index 630a7f19..493595d8 100644 --- a/frontend/src/pages/EmployeeDetailsPage.tsx +++ b/frontend/src/pages/EmployeeDetailsPage.tsx @@ -1,33 +1,77 @@ -import { useLocation, useParams } from 'react-router-dom'; +import { ArrowLeft } from 'lucide-react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ROUTE_PARAMS } from '@/routes/paths'; import type { UserResponse } from '@/features/user-management/user-management.types'; -import { RoleBadge } from '@/features/user-management/components/RoleBadge'; +import { useUserQuery } from '@/features/user-management/user-management.hooks'; +import { ProfileHeaderCard, ProfileSections, hasProfileSections } from '@/features/profile'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; export const EmployeeDetailsPage = () => { const { [ROUTE_PARAMS.USER_ID]: userId } = useParams(); const location = useLocation(); - const user = location.state?.user as UserResponse | undefined; + const navigate = useNavigate(); + const initialUser = location.state?.user as UserResponse | undefined; - const fullName = user?.name && user?.surname ? `${user.name} ${user.surname}` : userId; + const { data: fetchedUser, isLoading, isError } = useUserQuery(userId); + const user = fetchedUser ?? initialUser; + + if (!userId) return null; + + if (isLoading && !initialUser) { + return ( +
+ + + +
+ ); + } + + if (isError || !user) { + return ( +
+

+ Nie udało się pobrać profilu użytkownika. +

+
+ ); + } + + const headerUser = { + email: user.email, + role: user.role, + firstName: user.name ?? '', + lastName: user.surname ?? '', + }; return ( -
-
-
-

{fullName}

- {user && ( -
- {user.email} - · - -
- )} -
+
+
+ +
+

+ Profil użytkownika +

+
-
- Profil kompetencyjny pracownika — w trakcie realizacji + + + {hasProfileSections(user.role) ? ( + + ) : ( +
+ Konto administratora — brak dedykowanych sekcji biznesowych.
-
+ )}
); }; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 30fe933f..53467092 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,20 +1,36 @@ import { useAuth } from '@/providers/AuthContext'; -import { ProfileHeaderCard } from '@/features/profile'; -import { QualificationsCard } from '@/features/qualifications'; +import { useMyProfileQuery } from '@/features/user-management/user-management.hooks'; +import { ProfileHeaderCard, ProfileSections, hasProfileSections } from '@/features/profile'; +import { Skeleton } from '@/components/ui/skeleton'; export const ProfilePage = () => { const { user } = useAuth(); + const { data: profile, isLoading, isError } = useMyProfileQuery(); if (!user) return null; + const needsProfile = hasProfileSections(user.role); + return ( -
+

Mój profil

+

+ Twoje dane konta, kompetencje i ustawienia bezpieczeństwa. +

- + + {needsProfile && isLoading && } + + {needsProfile && isError && ( +

+ Nie udało się pobrać danych profilu. +

+ )} + + {profile && }
); };