Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package pl.edu.agh.project_manager.controller.approval;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationDetailsResponse;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationRequestResponse;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationUpdateRequest;
import pl.edu.agh.project_manager.controller.dto.employee_requests.EmployeeAssignmentDetailsResponse;
import pl.edu.agh.project_manager.controller.dto.project.AssignmentResponse;
import pl.edu.agh.project_manager.security.UserPrincipal;
import pl.edu.agh.project_manager.service.approval.AssignmentManagementService;
import pl.edu.agh.project_manager.service.approval.QualificationManagementService;
import pl.edu.agh.project_manager.service.project.ProjectAssignmentService;

import java.util.List;
Expand All @@ -17,12 +22,11 @@
@RestController
@RequestMapping("/api/approvals")
@RequiredArgsConstructor
public class AssignmentManagementController {
public class ApprovalsController {

private final ProjectAssignmentService assignmentService;
private final AssignmentManagementService assignmentManagementService;
// TODO: miejsce na pobieranie wniosków o zatwierdzenie kwalifikacji i akceptacje ich
// private final QualificationService qualificationService;
private final QualificationManagementService qualificationService;

@GetMapping("/assignments/pending")
@PreAuthorize("hasRole('LINEAR_MANAGER')")
Expand Down Expand Up @@ -59,8 +63,29 @@ public ResponseEntity<EmployeeAssignmentDetailsResponse> getAssignmentDetails(
@PathVariable UUID assignmentId,
@AuthenticationPrincipal UserPrincipal principal
) {

EmployeeAssignmentDetailsResponse details = assignmentManagementService.getEmployeeRequestDetails(assignmentId);
return ResponseEntity.ok(details);
}

@GetMapping("/qualifications")
@PreAuthorize("hasRole('LINEAR_MANAGER')")
public ResponseEntity<List<QualificationRequestResponse>> getUsersWithWaitingQualifications(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(qualificationService.getRecordsForManager(principal.userId()));
}

@GetMapping("/qualifications/details")
@PreAuthorize("hasRole('LINEAR_MANAGER') and @qualificationSecurity.isManagerForUser(principal.userId(), #userId)")
public ResponseEntity<List<QualificationDetailsResponse>> getQualificationRequestDetails(@RequestParam UUID userId) {
return ResponseEntity.ok(qualificationService.getPendingForUser(userId));
}

@PostMapping("/qualifications/bulk-update")
@PreAuthorize("hasRole('LINEAR_MANAGER')")
public ResponseEntity<Void> updateQualificationRequests(
@Valid @RequestBody List<QualificationUpdateRequest> requests,
@AuthenticationPrincipal UserPrincipal principal
) {
qualificationService.updateRequests(principal.userId(), requests);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package pl.edu.agh.project_manager.controller.dto.approvals;

import pl.edu.agh.project_manager.domain.entity.user.Qualification;

import java.util.UUID;

public record QualificationDetailsResponse(
UUID qualificationId,
String qualificationName
) {
public static QualificationDetailsResponse from(Qualification qualification) {
return new QualificationDetailsResponse(
qualification.getId(),
qualification.getSkill().getName()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package pl.edu.agh.project_manager.controller.dto.approvals;


import java.util.UUID;

public record QualificationRequestResponse(
UUID userId,
String employeeName,
String employeeSurname,
int qualificationsCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package pl.edu.agh.project_manager.controller.dto.approvals;

public enum QualificationUpdateAction {
ACCEPT,
REJECT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package pl.edu.agh.project_manager.controller.dto.approvals;

import jakarta.validation.constraints.NotNull;

import java.util.UUID;

public record QualificationUpdateRequest (
@NotNull(message = "Identyfikator kwalifikacji jest wymagany")
UUID qualificationId,

@NotNull(message = "Akcja (ACCEPT/REJECT/EMPTY) jest wymagana")
QualificationUpdateAction action
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,15 @@ public class Qualification {
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private QualificationStatus status = QualificationStatus.WAITING;

public void accept() {
this.status = QualificationStatus.ACCEPTED;
if (!skill.isValid()) {
skill.setValid(true);
}
}

public void reject() {
this.status = QualificationStatus.REJECTED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public enum ApiErrorCode {
USER_NOT_PENDING("USR_002", HttpStatus.BAD_REQUEST, "Cannot resend invitation — user is not in PENDING status"),

QUALIFICATION_NOT_FOUND("QUAL_001", HttpStatus.NOT_FOUND, "Qualification not found"),
INVALID_QUALIFICATION_STATE("QUAL_002", HttpStatus.BAD_REQUEST, "Qualification is not in WAITING state"),
QUALIFICATION_OWNER_NOT_SUBORDINATE("QUAL_003", HttpStatus.FORBIDDEN, "Qualification owner is not subordinate for manager"),

NOTIFICATION_NOT_FOUND("NOTIF_001", HttpStatus.NOT_FOUND, "Notification not found"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,35 @@

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import pl.edu.agh.project_manager.domain.entity.user.Qualification;
import pl.edu.agh.project_manager.domain.enums.QualificationStatus;

import java.util.Collection;
import java.util.List;
import java.util.UUID;

public interface QualificationRepository extends JpaRepository<Qualification, UUID> {
@EntityGraph(attributePaths = {"skill"})
List<Qualification> findAllByUser_Id(UUID userId);

List<Qualification> findAllByUserSupervisorIdAndStatus(UUID supervisorId, QualificationStatus qualificationStatus);

@Query("SELECT q FROM Qualification q " +
"JOIN FETCH q.skill " +
"WHERE q.user.id = :userId AND q.status = :status")
List<Qualification> findAllByUserIdAndStatus(
@Param("userId") UUID userId,
@Param("status") QualificationStatus status
);

@Query("SELECT q FROM Qualification q " +
"JOIN FETCH q.user u " +
"JOIN FETCH q.skill s " +
"WHERE q.id IN :ids AND u.supervisor.id = :managerId")
List<Qualification> findAllByIdInAndUserSupervisorId(
@Param("ids") Collection<UUID> ids,
@Param("managerId") UUID managerId
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package pl.edu.agh.project_manager.security.access;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import pl.edu.agh.project_manager.repository.user.UserRepository;

import java.util.UUID;

@Component("qualificationSecurity")
@RequiredArgsConstructor
public class QualificationSecurity {
private final UserRepository userRepository;

public boolean isManagerForUser(UUID managerId, UUID userId) {
return userRepository.findById(userId)
.map(user -> user.getSupervisor() != null &&
user.getSupervisor().getId().equals(managerId))
.orElse(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package pl.edu.agh.project_manager.service.approval;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationDetailsResponse;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationRequestResponse;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationUpdateAction;
import pl.edu.agh.project_manager.controller.dto.approvals.QualificationUpdateRequest;
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.QualificationStatus;
import pl.edu.agh.project_manager.domain.exception.ApiErrorCode;
import pl.edu.agh.project_manager.domain.exception.ApplicationException;
import pl.edu.agh.project_manager.repository.user.QualificationRepository;
import pl.edu.agh.project_manager.repository.user.UserRepository;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class QualificationManagementService {
private final QualificationRepository qualificationRepository;
private final UserRepository userRepository;

@Transactional(readOnly = true)
public List<QualificationRequestResponse> getRecordsForManager(UUID managerId) {
List<Qualification> qualifications = qualificationRepository.findAllByUserSupervisorIdAndStatus(managerId, QualificationStatus.WAITING);

return qualifications.stream()
.collect(Collectors.groupingBy(Qualification::getUser))
.entrySet().stream()
.map(entry -> {
User user = entry.getKey();
int count = entry.getValue().size();

return new QualificationRequestResponse(
user.getId(),
user.getName(),
user.getSurname(),
count
);
})
.toList();
}

@Transactional
public void updateRequests(UUID managerId, List<QualificationUpdateRequest> requests) {
var activeRequestsMap = requests.stream()
.collect(Collectors.toMap(QualificationUpdateRequest::qualificationId, r -> r));

if (activeRequestsMap.isEmpty()) return;

List<UUID> ids = List.copyOf(activeRequestsMap.keySet());

List<Qualification> allowedQualifications = qualificationRepository.findAllByIdInAndUserSupervisorId(ids, managerId);

if (allowedQualifications.size() != ids.size()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nie powinniśmy rzucać ACCESS_DENIED w sekcji z logiką biznesową, a raczej taki rodzaj błędu tutaj niezbyt pasuje.

throw new ApplicationException(ApiErrorCode.QUALIFICATION_OWNER_NOT_SUBORDINATE);
}

for (Qualification qualification : allowedQualifications) {
validateWaitingStatus(qualification);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jeżeli chcemy tytlko WAITING to można też odrazu z bazy pobrać tylko kwalifikacje z WAITING.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

z uwagi na to, że wyżej ma miejsce taka walidacja:

        if (allowedQualifications.size() != ids.size()) {
            throw new ApplicationException(ApiErrorCode.QUALIFICATION_OWNER_NOT_SUBORDINATE);
        }

to rozmiary mogłyby się nie zgadzać m.in. przez wyciągnięcie samych rekordów ze statusem WAITING, chyba najprościej jest to rozwiązać wyciągając wszystkie i sprawdzać na dalszym etapie czy coś ma inny status niż waiting


var action = activeRequestsMap.get(qualification.getId()).action();

switch (action) {
case QualificationUpdateAction.ACCEPT -> qualification.accept();
case QualificationUpdateAction.REJECT -> qualification.reject();
}
}
}

@Transactional(readOnly = true)
public List<QualificationDetailsResponse> getPendingForUser(UUID userId) {
if (!userRepository.existsById(userId)) {
throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND);
}

return qualificationRepository.findAllByUserIdAndStatus(userId, QualificationStatus.WAITING)
.stream()
.map(QualificationDetailsResponse::from)
.toList();
}

private void validateWaitingStatus(Qualification qualification) {
if (qualification.getStatus() != QualificationStatus.WAITING) {
throw new ApplicationException(ApiErrorCode.INVALID_QUALIFICATION_STATE);
}
}
}
5 changes: 4 additions & 1 deletion frontend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export const ENDPOINTS = {
ASSIGNMENTS: '/approvals/assignments/pending',
ASSIGNMENT_DETAIL: (id: string) => `/approvals/assignments/${id}/details`,
ACCEPT_ASSIGNMENT: (id: string) => `/approvals/assignments/${id}/accept`,
REJECT_ASSIGNMENT: (id: string) => `/approvals/assignments/${id}/reject`
REJECT_ASSIGNMENT: (id: string) => `/approvals/assignments/${id}/reject`,
QUALIFICATIONS: '/approvals/qualifications',
QUALIFICATION_DETAILS: '/approvals/qualifications/details',
QUALIFICATIONS_BULK_UPDATE: '/approvals/qualifications/bulk-update'
}
} as const;
13 changes: 12 additions & 1 deletion frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,15 @@ export const employeeAssignmentsKeys = createQueryKeys('employee-assignments', {
list: null
})

export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys)
export const qualificationsKeys = createQueryKeys('qualifications', {
mine: null, // me/qualifications
suggestions: (query: string) => [query],
});

export const approvalsKeys = createQueryKeys('approvals', {
waitingSummary: null,
details: (userId: string) => [userId],
});


export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, qualificationsKeys, approvalsKeys);
2 changes: 1 addition & 1 deletion frontend/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const MainLayout = () => {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<Navbar />
<main className="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
<main className="flex-1 flex flex-col max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
<Outlet />
</main>
</div>
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/components/layout/TablePageShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
interface TablePageShellProps {
title: string;
description?: string;
children: React.ReactNode;
isLoading?: boolean;
}

export const TablePageShell = ({ title, description, children, isLoading }: TablePageShellProps) => {
return (
<div className="flex-1 flex flex-col space-y-6 w-full">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && <p className="text-muted-foreground text-sm">{description}</p>}
</div>

<div className="flex-1 flex flex-col rounded-md border bg-white shadow-sm">
{isLoading ? (
<div className="flex flex-1 items-center justify-center min-h-[400px]">
<p className="text-sm animate-pulse text-muted-foreground">Ładowanie...</p>
</div>
) : (
children
)}
</div>
</div>
);
};
Loading
Loading