From af31a3587ab4e7416840584ef38b594c633c1ed8 Mon Sep 17 00:00:00 2001 From: radek Date: Sat, 9 May 2026 00:33:48 +0200 Subject: [PATCH 01/39] kluczowe zmiany --- .../advice/GlobalExceptionHandler.java | 19 ++ .../ChartIntervalResponse.java | 2 +- .../EmployeeAssignmentDetailsResponse.java | 2 + .../EmployeeAssignmentRequestStatus.java | 7 - .../dto/project/AssignmentResponse.java | 18 +- .../dto/project/AssignmentStatusResponse.java | 17 ++ ...ProjectAssignmentUserWorkloadResponse.java | 10 + .../controller/user/UserController.java | 9 + .../domain/exception/ApiErrorCode.java | 3 +- .../approval/AssignmentManagementService.java | 65 +---- .../project/ProjectAssignmentService.java | 4 +- .../service/user/UserService.java | 3 + .../util/assignments/AssignmentsUtil.java | 60 +++++ .../AssignmentManagementServiceTest.java | 2 +- .../project/ProjectAssignmentServiceTest.java | 2 +- frontend/package-lock.json | 71 +++++- frontend/package.json | 7 +- frontend/src/api/endpoints.ts | 18 +- frontend/src/components/ui/button.tsx | 2 +- frontend/src/components/ui/calendar.tsx | 222 ++++++++++++++++++ frontend/src/components/ui/command.tsx | 193 +++++++++++++++ frontend/src/components/ui/date-picker.tsx | 43 ++++ frontend/src/components/ui/form.tsx | 172 ++++++++++++++ frontend/src/components/ui/input-group.tsx | 156 ++++++++++++ frontend/src/components/ui/popover.tsx | 87 +++++++ frontend/src/components/ui/slider.tsx | 38 +++ frontend/src/components/ui/textarea.tsx | 18 ++ .../components/EmployeeAssignmentDetails.tsx | 2 +- .../components/EmployeeWorkloadChart.tsx | 17 +- .../employee-assignments.columns.tsx | 2 +- .../employee-assignments.utils.ts | 2 +- .../components/CreateAssignmentDetails.tsx | 204 ++++++++++++++++ .../components/CreateAssignmentModal.tsx | 171 ++++++++++++++ .../project/components/CreateProjectForm.tsx | 7 +- .../project/components/ProjectRolesStatus.tsx | 176 -------------- .../components/SelectedUserRow.view.tsx | 47 ++++ .../src/features/project/project.hooks.ts | 27 +-- .../src/features/project/project.schema.ts | 22 +- .../src/features/project/project.service.ts | 18 +- .../src/features/project/project.types.ts | 30 +-- .../features/user-management/query-keys.ts | 1 + .../user-management/user-management.hooks.ts | 42 +++- .../user-management.service.ts | 7 +- .../user-management/user-management.types.ts | 5 + frontend/src/pages/ProjectDetailsPage.tsx | 48 ++-- 45 files changed, 1702 insertions(+), 376 deletions(-) rename backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/{employee_requests => common}/ChartIntervalResponse.java (68%) delete mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/employee_requests/EmployeeAssignmentRequestStatus.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/AssignmentStatusResponse.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/project/ProjectAssignmentUserWorkloadResponse.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/util/assignments/AssignmentsUtil.java create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/date-picker.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/input-group.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/features/project/components/CreateAssignmentDetails.tsx create mode 100644 frontend/src/features/project/components/CreateAssignmentModal.tsx delete mode 100644 frontend/src/features/project/components/ProjectRolesStatus.tsx create mode 100644 frontend/src/features/project/components/SelectedUserRow.view.tsx 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/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/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/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/user/UserController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/user/UserController.java index acf14e24..79f3e485 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 @@ -11,11 +11,14 @@ import pl.edu.agh.project_manager.controller.dto.invitation.AdminUserInvitationRequest; import pl.edu.agh.project_manager.controller.dto.invitation.ManagerUserInvitationRequest; 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.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.security.UserPrincipal; +import pl.edu.agh.project_manager.service.approval.AssignmentManagementService; +import pl.edu.agh.project_manager.service.project.ProjectAssignmentService; 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; @@ -31,6 +34,7 @@ class UserController { private final UserInvitationService invitationService; private final UserService userService; + private final AssignmentManagementService assignmentManagementService; @GetMapping("/users") @PreAuthorize("isAuthenticated()") @@ -95,4 +99,9 @@ public ResponseEntity createInviteAsManager( public ResponseEntity> searchUsers(@RequestParam("search") String search) { return ResponseEntity.ok(userService.searchUsers(search)); } + + @GetMapping("/users/{userId}/workload") + public ResponseEntity getUserWorkload(@PathVariable UUID userId) { + return ResponseEntity.ok(assignmentManagementService.getUserWorkload(userId)); + } } 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 b5aa4cbd..3e94271d 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,6 +22,7 @@ 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"), @@ -41,8 +42,8 @@ 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"), 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/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/project/ProjectAssignmentService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java index 3a6a0f40..9ccfa7e9 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 @@ -137,14 +137,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/user/UserService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserService.java index 0afd1166..a65f2fea 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,8 +10,10 @@ 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.user.SimpleUserResponse; import pl.edu.agh.project_manager.controller.dto.user.UserResponse; +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.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; @@ -19,6 +21,7 @@ import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.project.ProjectRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; +import pl.edu.agh.project_manager.util.assignments.AssignmentsUtil; import java.util.ArrayList; import java.util.List; 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/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/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/frontend/package-lock.json b/frontend/package-lock.json index e47ddf73..2aa0474d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,12 +17,15 @@ "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "radix-ui": "^1.4.3", "react": "^19.2.4", + "react-day-picker": "^10.0.0", "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 +33,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 +458,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", @@ -4496,6 +4505,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 +4829,16 @@ "node": ">= 12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "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", @@ -7788,6 +7823,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.0.tgz", + "integrity": "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==", + "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": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -7801,9 +7856,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 +9440,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..019021d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,12 +19,15 @@ "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "radix-ui": "^1.4.3", "react": "^19.2.4", + "react-day-picker": "^10.0.0", "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 +35,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/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 45268bf6..62371f6b 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -9,6 +9,7 @@ export const ENDPOINTS = { LIST: '/users', DETAIL: (id: string) => `/users/${id}`, RESEND_INVITATION: '/users/invitation', + WORKLOAD: (id?: string) => `/users/${id}/workload`, SEARCH_USERS: '/users/search' }, ME: { @@ -22,17 +23,12 @@ export const ENDPOINTS = { }, PROJECT: { BASE: '/projects', - LIST: '/projects', - CREATE: '/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` + }), }, PROJECT_GROUP: { LIST_ALL: '/groups', 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/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 00000000..b86a02e0 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,172 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = 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 ( +