From d1f000bc50f940397c537eadfed89441bfd39797 Mon Sep 17 00:00:00 2001 From: Radek Date: Sat, 9 May 2026 23:22:46 +0200 Subject: [PATCH 1/7] obsluga wnioskow o kwalifikacje pracownikow --- ...ntroller.java => ApprovalsController.java} | 36 ++++- .../QualificationDetailsResponse.java | 17 +++ .../QualificationRequestResponse.java | 12 ++ .../approvals/QualificationUpdateAction.java | 7 + .../approvals/QualificationUpdateRequest.java | 15 ++ .../domain/exception/ApiErrorCode.java | 1 + .../user/QualificationRepository.java | 26 ++++ .../QualificationManagementService.java | 104 +++++++++++++ frontend/src/api/endpoints.ts | 5 +- frontend/src/api/index.ts | 13 +- frontend/src/components/layout/MainLayout.tsx | 2 +- .../src/components/layout/TablePageShell.tsx | 27 ++++ frontend/src/components/ui/data-table.tsx | 121 ++++++++++----- .../QualificationRequestDetails.tsx | 138 ++++++++++++++++++ .../qualifications/qualifications.columns.tsx | 58 ++++++++ .../qualifications/qualifications.hooks.ts | 51 ++++++- .../qualifications/qualifications.service.ts | 18 ++- .../qualifications/qualifications.types.ts | 25 ++++ .../src/features/qualifications/query-keys.ts | 2 - .../src/pages/EmployeeAssignmentsPage.tsx | 34 ++--- .../src/pages/QualificationRequestsPage.tsx | 52 +++++++ frontend/src/routes/AppRoutes.tsx | 4 +- frontend/src/routes/navigation.ts | 13 +- frontend/src/routes/paths.ts | 5 +- 24 files changed, 707 insertions(+), 79 deletions(-) rename backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/{AssignmentManagementController.java => ApprovalsController.java} (63%) create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationDetailsResponse.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationRequestResponse.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateAction.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateRequest.java create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java create mode 100644 frontend/src/components/layout/TablePageShell.tsx create mode 100644 frontend/src/features/qualifications/components/QualificationRequestDetails.tsx create mode 100644 frontend/src/features/qualifications/qualifications.columns.tsx delete mode 100644 frontend/src/features/qualifications/query-keys.ts create mode 100644 frontend/src/pages/QualificationRequestsPage.tsx diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/AssignmentManagementController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java similarity index 63% rename from backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/AssignmentManagementController.java rename to backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java index d1dc0489..457eac70 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/AssignmentManagementController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java @@ -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; @@ -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')") @@ -59,8 +63,32 @@ public ResponseEntity getAssignmentDetails( @PathVariable UUID assignmentId, @AuthenticationPrincipal UserPrincipal principal ) { - EmployeeAssignmentDetailsResponse details = assignmentManagementService.getEmployeeRequestDetails(assignmentId); return ResponseEntity.ok(details); } + + @GetMapping("/qualifications") + @PreAuthorize("hasRole('LINEAR_MANAGER')") + public ResponseEntity> getUsersWithWaitingQualifications(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(qualificationService.getRecordsForManager(principal.userId())); + } + + @GetMapping("/qualifications/details") + @PreAuthorize("hasRole('LINEAR_MANAGER')") + public ResponseEntity> getQualificationRequestDetails( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam UUID userId + ) { + return ResponseEntity.ok(qualificationService.getPendingForUser(principal.userId(), userId)); + } + + @PostMapping("/qualifications/bulk-update") + @PreAuthorize("hasRole('LINEAR_MANAGER')") + public ResponseEntity updateQualificationRequests( + @Valid @RequestBody List requests, + @AuthenticationPrincipal UserPrincipal principal + ) { + qualificationService.updateRequests(principal.userId(), requests); + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationDetailsResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationDetailsResponse.java new file mode 100644 index 00000000..7cb83061 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationDetailsResponse.java @@ -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() + ); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationRequestResponse.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationRequestResponse.java new file mode 100644 index 00000000..d3e22f15 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationRequestResponse.java @@ -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 +) { +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateAction.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateAction.java new file mode 100644 index 00000000..9e505af1 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateAction.java @@ -0,0 +1,7 @@ +package pl.edu.agh.project_manager.controller.dto.approvals; + +public enum QualificationUpdateAction { + ACCEPT, + REJECT, + EMPTY +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateRequest.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateRequest.java new file mode 100644 index 00000000..811ae08f --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateRequest.java @@ -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 +) { + +} 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..8394cceb 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 @@ -31,6 +31,7 @@ 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"), ROLE_NOT_IN_PROJECT("REQ_001", HttpStatus.BAD_REQUEST, "Role must exist within the provided project"), USER_ALREADY_IN_PROJECT("REQ_002", HttpStatus.CONFLICT, "User is already a member of this project"), diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/QualificationRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/QualificationRepository.java index eb5c6873..98e69ec5 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/QualificationRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/user/QualificationRepository.java @@ -2,12 +2,38 @@ 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.project.ProjectAssignment; import pl.edu.agh.project_manager.domain.entity.user.Qualification; +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.QualificationStatus; +import java.util.Collection; import java.util.List; import java.util.UUID; public interface QualificationRepository extends JpaRepository { @EntityGraph(attributePaths = {"skill"}) List findAllByUser_Id(UUID userId); + + List 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 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 findAllByIdInAndUserSupervisorId( + @Param("ids") Collection ids, + @Param("managerId") UUID managerId + ); } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java new file mode 100644 index 00000000..0feb14fa --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -0,0 +1,104 @@ +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 getRecordsForManager(UUID managerId) { + List 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 requests) { + var activeRequestsMap = requests.stream() + .filter(r -> r.action() != QualificationUpdateAction.EMPTY) + .collect(Collectors.toMap( + QualificationUpdateRequest::qualificationId, + r -> r, + (existing, replacement) -> existing + )); + + if (activeRequestsMap.isEmpty()) return; + + List ids = List.copyOf(activeRequestsMap.keySet()); + + List allowedQualifications = qualificationRepository.findAllByIdInAndUserSupervisorId(ids, managerId); + + if (allowedQualifications.size() != ids.size()) { + throw new ApplicationException(ApiErrorCode.ACCESS_DENIED); + } + + for (Qualification qualification : allowedQualifications) { + var action = activeRequestsMap.get(qualification.getId()).action(); + + validateWaitingStatus(qualification); + + if (action == QualificationUpdateAction.ACCEPT) { + qualification.setStatus(QualificationStatus.ACCEPTED); + Skill skill = qualification.getSkill(); + if (!skill.isValid()) { + skill.setValid(true); + } + } else { + qualification.setStatus(QualificationStatus.REJECTED); + } + } + } + + @Transactional(readOnly = true) + public List getPendingForUser(UUID managerId, 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); + } + } +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 45268bf6..f2e86ebf 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -45,6 +45,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; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index af50dd08..c7c39c6d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,4 +5,15 @@ export const employeeAssignmentsKeys = createQueryKeys('employee-assignments', { list: null }) -export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys) \ No newline at end of file +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); \ No newline at end of file diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 77ed4600..ed36ba5c 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -5,7 +5,7 @@ export const MainLayout = () => { return (
-
+
diff --git a/frontend/src/components/layout/TablePageShell.tsx b/frontend/src/components/layout/TablePageShell.tsx new file mode 100644 index 00000000..f7a916b6 --- /dev/null +++ b/frontend/src/components/layout/TablePageShell.tsx @@ -0,0 +1,27 @@ +interface TablePageShellProps { + title: string; + description?: string; + children: React.ReactNode; + isLoading?: boolean; +} + +export const TablePageShell = ({ title, description, children, isLoading }: TablePageShellProps) => { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ +
+ {isLoading ? ( +
+

Ładowanie...

+
+ ) : ( + children + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index b6068483..745c7b9d 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -4,6 +4,8 @@ import { type ColumnDef, flexRender, getCoreRowModel, + getPaginationRowModel, + type PaginationState, useReactTable, } from "@tanstack/react-table" @@ -15,6 +17,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { useState } from "react" +import { Button } from "./button" interface DataTableProps { columns: ColumnDef[] @@ -25,56 +29,101 @@ export function DataTable({ columns, data, }: DataTableProps) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination, + }, }) return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + Brak wyników. + + + )} + + +
+ +
+
+ Łącznie: {data.length} pozycji +
+ +
+
+ Strona {table.getPageCount() === 0 ? 0 : table.getState().pagination.pageIndex + 1} z{" "} + {table.getPageCount()} +
+ +
+ + +
+
+
) } \ No newline at end of file diff --git a/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx b/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx new file mode 100644 index 00000000..7fc779ee --- /dev/null +++ b/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { useQualificationDetailsQuery, useUpdateQualifications } from "../qualifications.hooks"; +import { QualificationUpdateAction, type QualificationUpdateRequest } from "../qualifications.types"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Check, X, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface QualificationRequestDetailsProps { + userId: string; + onClose: () => void; +} + +export const QualificationRequestDetails = ({ userId, onClose }: QualificationRequestDetailsProps) => { + const { rowDetails, isLoading } = useQualificationDetailsQuery(userId); + const { updateQualifications, isUpdating } = useUpdateQualifications(); + + const [decisions, setDecisions] = useState>({}); + + // Obliczamy payload na bieżąco, aby wiedzieć, czy faktycznie mamy coś do zapisania + const pendingPayload: QualificationUpdateRequest[] = Object.entries(decisions) + .filter(([_, action]) => action !== QualificationUpdateAction.EMPTY) + .map(([qualificationId, action]) => ({ + qualificationId, + action, + })); + + const hasChanges = pendingPayload.length > 0; + + const handleActionChange = (qualificationId: string, action: QualificationUpdateAction) => { + setDecisions((prev) => ({ + ...prev, + [qualificationId]: prev[qualificationId] === action ? QualificationUpdateAction.EMPTY : action, + })); + }; + + const handleSave = () => { + if (!hasChanges) { + onClose(); + return; + } + + updateQualifications(pendingPayload, { + onSuccess: () => onClose(), + }); + }; + + if (isLoading) { + return ( +
+ +

Pobieranie szczegółów...

+
+ ); + } + + // Obsługa przypadku, gdy nie ma żadnych wniosków do wyświetlenia + if (!rowDetails || rowDetails.length === 0) { + return ( +
+

Brak aktywnych wniosków dla tego użytkownika.

+ +
+ ); + } + + return ( +
+
+ + + + Nazwa kwalifikacji + Decyzja + + + + {rowDetails.map((req) => { + const currentAction = decisions[req.qualificationId] || QualificationUpdateAction.EMPTY; + + return ( + + {req.qualificationName} + +
+ + +
+
+
+ ); + })} +
+
+
+ +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/qualifications/qualifications.columns.tsx b/frontend/src/features/qualifications/qualifications.columns.tsx new file mode 100644 index 00000000..b5887df3 --- /dev/null +++ b/frontend/src/features/qualifications/qualifications.columns.tsx @@ -0,0 +1,58 @@ +"use client" + +import type { ColumnDef } from "@tanstack/react-table" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import type { QualificationRequestResponse } from "./qualifications.types" + + + +interface ColumnActions { + onVerify?: (userSummary: QualificationRequestResponse) => void +} + +export const getColumns = ({ onVerify }: ColumnActions): ColumnDef[] => [ + { + accessorFn: (row) => `${row.employeeName} ${row.employeeSurname}`, + id: "fullName", + header: "Imię i nazwisko", + }, + { + accessorKey: "qualificationsCount", + header: "Status wniosków", + cell: ({ row }) => { + const count = row.original.qualificationsCount; + + return ( + + {count} {count === 1 ? 'WNIOSEK' : 'WNIOSKI'} OCZEKUJĄCE + + ); + }, + }, + { + id: "actions", + header: () =>
Akcje
, + cell: ({ row }) => { + const userSummary = row.original; + + return ( +
+ +
+ ); + }, +}, +] \ No newline at end of file diff --git a/frontend/src/features/qualifications/qualifications.hooks.ts b/frontend/src/features/qualifications/qualifications.hooks.ts index 45ad402d..34688ea4 100644 --- a/frontend/src/features/qualifications/qualifications.hooks.ts +++ b/frontend/src/features/qualifications/qualifications.hooks.ts @@ -1,11 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { qualificationsService } from './qualifications.service'; -import { MY_QUALIFICATIONS_QUERY_KEY, SKILL_SUGGESTIONS_QUERY_KEY } from './query-keys'; import type { AddQualificationRequest } from './qualifications.types'; +import { queryKeys } from '@/api'; export const useMyQualificationsQuery = () => useQuery({ - queryKey: MY_QUALIFICATIONS_QUERY_KEY, + queryKey: queryKeys.qualifications.mine.queryKey, queryFn: qualificationsService.getMyQualifications, }); @@ -14,7 +14,7 @@ export const useAddQualificationsMutation = () => { return useMutation({ mutationFn: (data: AddQualificationRequest) => qualificationsService.addQualifications(data), - onSuccess: () => queryClient.invalidateQueries({ queryKey: MY_QUALIFICATIONS_QUERY_KEY }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.qualifications.mine.queryKey }), }); }; @@ -23,14 +23,55 @@ export const useDeleteQualificationMutation = () => { return useMutation({ mutationFn: (id: string) => qualificationsService.deleteQualification(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: MY_QUALIFICATIONS_QUERY_KEY }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.qualifications.mine.queryKey }), }); }; export const useSkillSuggestionsQuery = (query: string) => useQuery({ - queryKey: SKILL_SUGGESTIONS_QUERY_KEY(query), + queryKey: queryKeys.qualifications.suggestions(query).queryKey, queryFn: () => qualificationsService.searchSkills(query), enabled: query.length >= 2, staleTime: 30_000, }); + +export const useWaitingQualificationsSummaryQuery = () => { + const waitingQualificationsQuery = useQuery({ + queryKey: queryKeys.approvals.waitingSummary.queryKey, + queryFn: qualificationsService.getUsersWithWaitingQualifications + }); + + return { + waitingQualificationsSummary: waitingQualificationsQuery.data, + isLoading: waitingQualificationsQuery.isPending + } +} + +export const useQualificationDetailsQuery = (userId: string) => { + const qualificationsQuery = useQuery({ + queryKey: queryKeys.approvals.details(userId).queryKey, + queryFn: () => qualificationsService.getQualificationRequestDetails(userId), + enabled: !!userId, // zapytanie uruchomi się tylko gdy mamy userId + }); + + return { + rowDetails: qualificationsQuery.data, + isLoading: qualificationsQuery.isPending + } +} + +export const useUpdateQualifications = () => { + const queryClient = useQueryClient(); + + const updateMutation = useMutation({ + mutationFn: qualificationsService.updateQualificationRequests, + onSuccess: () => { + queryClient.invalidateQueries(); + } + }); + + return { + updateQualifications: updateMutation.mutate, + isUpdating: updateMutation.isPending, + } +} diff --git a/frontend/src/features/qualifications/qualifications.service.ts b/frontend/src/features/qualifications/qualifications.service.ts index b4e41326..c7f6f7df 100644 --- a/frontend/src/features/qualifications/qualifications.service.ts +++ b/frontend/src/features/qualifications/qualifications.service.ts @@ -1,6 +1,6 @@ import api from '@/api/client'; import { ENDPOINTS } from '@/api/endpoints'; -import type { AddQualificationRequest, QualificationResponse, SkillSuggestion } from './qualifications.types'; +import type { AddQualificationRequest, QualificationDetailsResponse, QualificationRequestResponse, QualificationResponse, QualificationUpdateRequest, SkillSuggestion } from './qualifications.types'; export const qualificationsService = { getMyQualifications: async (): Promise => { @@ -21,4 +21,20 @@ export const qualificationsService = { const res = await api.get(ENDPOINTS.SKILLS, { params: { query } }); return res.data; }, + + getUsersWithWaitingQualifications: async (): Promise => { + const res = await api.get(ENDPOINTS.APPROVALS.QUALIFICATIONS); + return res.data; + }, + + getQualificationRequestDetails: async (userId: string): Promise => { + const res = await api.get(ENDPOINTS.APPROVALS.QUALIFICATION_DETAILS, { + params: { userId } + }); + return res.data; + }, + + updateQualificationRequests: async (requests: QualificationUpdateRequest[]): Promise => { + await api.post(ENDPOINTS.APPROVALS.QUALIFICATIONS_BULK_UPDATE, requests); + } }; diff --git a/frontend/src/features/qualifications/qualifications.types.ts b/frontend/src/features/qualifications/qualifications.types.ts index b567213f..b9c49189 100644 --- a/frontend/src/features/qualifications/qualifications.types.ts +++ b/frontend/src/features/qualifications/qualifications.types.ts @@ -26,3 +26,28 @@ export interface PendingSkill { name: string; id?: string; } + +export const QualificationUpdateAction = { + ACCEPT: 'ACCEPT', + REJECT: 'REJECT', + EMPTY: 'EMPTY', +} as const; + +export type QualificationUpdateAction = typeof QualificationUpdateAction[keyof typeof QualificationUpdateAction]; + +export interface QualificationUpdateRequest { + qualificationId: string; + action: QualificationUpdateAction; +} + +export interface QualificationRequestResponse { + userId: string; + employeeName: string; + employeeSurname: string; + qualificationsCount: number; +} + +export interface QualificationDetailsResponse { + qualificationId: string; + qualificationName: string; +} \ No newline at end of file diff --git a/frontend/src/features/qualifications/query-keys.ts b/frontend/src/features/qualifications/query-keys.ts deleted file mode 100644 index cd03a485..00000000 --- a/frontend/src/features/qualifications/query-keys.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const MY_QUALIFICATIONS_QUERY_KEY = ['me', 'qualifications'] as const; -export const SKILL_SUGGESTIONS_QUERY_KEY = (query: string) => ['skills', 'suggestions', query] as const; diff --git a/frontend/src/pages/EmployeeAssignmentsPage.tsx b/frontend/src/pages/EmployeeAssignmentsPage.tsx index efffb5c8..3c5c9e91 100644 --- a/frontend/src/pages/EmployeeAssignmentsPage.tsx +++ b/frontend/src/pages/EmployeeAssignmentsPage.tsx @@ -5,6 +5,7 @@ import type { EmployeeAssignment } from "@/features/employee-assignments/employe import { EmployeeAssignmentDetails } from "@/features/employee-assignments/components/EmployeeAssignmentDetails"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { DataTable } from "@/components/ui/data-table"; +import { TablePageShell } from "@/components/layout/TablePageShell"; export const EmployeeAssignmentsPage = () => { const { employeeAssignments, areAssignmentsLoading } = useEmployeeAssignments(); @@ -25,25 +26,15 @@ export const EmployeeAssignmentsPage = () => { const handleCloseModal = () => setSelectedAssignment(null); return ( -
-
-
-

Wnioski projektowe

-

- Zarządzaj wnioskami o udział pracowników w projektach. -

-
- - {areAssignmentsLoading ? ( -
-

Ładowanie wniosków...

-
- ) : ( - - )} - - !open && handleCloseModal()}> - + + + + !open && setSelectedAssignment(null)}> + Szczegóły weryfikacji @@ -54,8 +45,7 @@ export const EmployeeAssignmentsPage = () => { onClose={handleCloseModal} />} - -
-
+ + ); }; \ No newline at end of file diff --git a/frontend/src/pages/QualificationRequestsPage.tsx b/frontend/src/pages/QualificationRequestsPage.tsx new file mode 100644 index 00000000..702c2e6f --- /dev/null +++ b/frontend/src/pages/QualificationRequestsPage.tsx @@ -0,0 +1,52 @@ +import { TablePageShell } from "@/components/layout/TablePageShell"; +import { DataTable } from "@/components/ui/data-table"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { QualificationRequestDetails } from "@/features/qualifications/components/QualificationRequestDetails"; +import { getColumns } from "@/features/qualifications/qualifications.columns"; +import { useWaitingQualificationsSummaryQuery } from "@/features/qualifications/qualifications.hooks"; +import type { QualificationRequestResponse } from "@/features/qualifications/qualifications.types"; +import { useMemo, useState } from "react"; + +export const QualificationRequestsPage = () => { + const { waitingQualificationsSummary, isLoading } = useWaitingQualificationsSummaryQuery(); + const [selectedRequest, setselectedRequest] = useState(null); + + const columns = useMemo(() => getColumns({ + onVerify: (user) => setselectedRequest(user) + }), []); + + const handleCloseModal = () => setselectedRequest(null); + + return ( + + + !open && handleCloseModal()}> + + + + Weryfikacja: {selectedRequest?.employeeName} {selectedRequest?.employeeSurname} + +

+ Zatwierdź lub odrzuć zgłoszone kwalifikacje pracownika. +

+
+ + {selectedRequest && ( + + )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index ecbef29d..942eaff4 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -15,6 +15,7 @@ import { DashboardPage } from "@/pages/DashboardPage"; import { AuthenticatedRoute } from '@/routes/AuthenticatedRoute'; import { AuthorizedRoute } from '@/routes/AuthorizedRoute'; import { EmployeeAssignmentsPage } from '@/pages/EmployeeAssignmentsPage'; +import { QualificationRequestsPage } from '@/pages/QualificationRequestsPage'; export const AppRoutes = () => { return ( @@ -35,7 +36,8 @@ export const AppRoutes = () => { {/* LINEAR MANAGER ROUTES */} }> - } /> + } /> + } /> {/* PROJECT MANAGER ROUTES */} diff --git a/frontend/src/routes/navigation.ts b/frontend/src/routes/navigation.ts index 12c3f8be..d0a38dba 100644 --- a/frontend/src/routes/navigation.ts +++ b/frontend/src/routes/navigation.ts @@ -2,7 +2,8 @@ import { LayoutDashboard, ClipboardList, FolderPlus, - ShieldAlert + ShieldAlert, + GraduationCap } from 'lucide-react'; import { UserRole } from '@/features/auth/auth.types'; import { PATHS } from '@/routes/paths'; @@ -22,10 +23,16 @@ export const NAV_ITEMS: NavItem[] = [ icon: LayoutDashboard, }, { - label: 'Wnioski', - path: PATHS.REQUESTS, + label: 'Wnioski projektowe', + path: PATHS.PROJECT_REQUESTS, icon: ClipboardList, roles: [UserRole.LINEAR_MANAGER], + }, + { + label: 'Wnioski kwalifikacyjne', + path: PATHS.QUALIFICATION_REQUESTS, + icon: GraduationCap, + roles: [UserRole.LINEAR_MANAGER], }, { label: 'Nowy projekt', diff --git a/frontend/src/routes/paths.ts b/frontend/src/routes/paths.ts index 3120bca5..67dcce28 100644 --- a/frontend/src/routes/paths.ts +++ b/frontend/src/routes/paths.ts @@ -13,9 +13,10 @@ export const PATHS = { LOGIN: '/login', REGISTER: `/register`, PROFILE: `/profile`, - REQUESTS: `/requests`, + PROJECT_REQUESTS: `/project-requests`, ADMIN_USERS: '/admin/users', ADMIN_USER_DETAILS: (userId: string) => `/admin/users/${userId}`, CREATE_PROJECT: `/create-project`, - PROJECT: (id: string) => `project/${id}` + PROJECT: (id: string) => `project/${id}`, + QUALIFICATION_REQUESTS: '/qualification-requests', } as const; \ No newline at end of file From f8811f2380506ed86b52865b6041e41e8a697786 Mon Sep 17 00:00:00 2001 From: Radek Date: Sat, 9 May 2026 23:23:24 +0200 Subject: [PATCH 2/7] fix --- frontend/src/pages/EmployeeAssignmentsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/EmployeeAssignmentsPage.tsx b/frontend/src/pages/EmployeeAssignmentsPage.tsx index 3c5c9e91..c2b4736f 100644 --- a/frontend/src/pages/EmployeeAssignmentsPage.tsx +++ b/frontend/src/pages/EmployeeAssignmentsPage.tsx @@ -12,7 +12,6 @@ export const EmployeeAssignmentsPage = () => { const { rejectRequest } = useEmployeeAssignmentsActions(); const [selectedAssignment, setSelectedAssignment] = useState(null); - const isModalOpen = !!selectedAssignment; const handleReject = useCallback((assignment: EmployeeAssignment) => { rejectRequest(assignment.id); From 73bdd197e749ab46491801fe6c4cb8ff93d7708e Mon Sep 17 00:00:00 2001 From: Radek Date: Sun, 10 May 2026 16:03:01 +0200 Subject: [PATCH 3/7] poprawki --- .../QualificationRequestDetails.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx b/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx index 7fc779ee..31230902 100644 --- a/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx +++ b/frontend/src/features/qualifications/components/QualificationRequestDetails.tsx @@ -17,21 +17,25 @@ export const QualificationRequestDetails = ({ userId, onClose }: QualificationRe const [decisions, setDecisions] = useState>({}); - // Obliczamy payload na bieżąco, aby wiedzieć, czy faktycznie mamy coś do zapisania - const pendingPayload: QualificationUpdateRequest[] = Object.entries(decisions) - .filter(([_, action]) => action !== QualificationUpdateAction.EMPTY) - .map(([qualificationId, action]) => ({ + const pendingPayload: QualificationUpdateRequest[] = Object.entries(decisions).map( + ([qualificationId, action]) => ({ qualificationId, action, - })); + }) + ); const hasChanges = pendingPayload.length > 0; const handleActionChange = (qualificationId: string, action: QualificationUpdateAction) => { - setDecisions((prev) => ({ - ...prev, - [qualificationId]: prev[qualificationId] === action ? QualificationUpdateAction.EMPTY : action, - })); + setDecisions((prev) => { + const newState = { ...prev }; + if (prev[qualificationId] === action) { + delete newState[qualificationId]; + } else { + newState[qualificationId] = action; + } + return newState; + }); }; const handleSave = () => { @@ -54,7 +58,6 @@ export const QualificationRequestDetails = ({ userId, onClose }: QualificationRe ); } - // Obsługa przypadku, gdy nie ma żadnych wniosków do wyświetlenia if (!rowDetails || rowDetails.length === 0) { return (
@@ -76,22 +79,22 @@ export const QualificationRequestDetails = ({ userId, onClose }: QualificationRe {rowDetails.map((req) => { - const currentAction = decisions[req.qualificationId] || QualificationUpdateAction.EMPTY; + const currentAction = decisions[req.qualificationId]; return ( - {req.qualificationName} + + {req.qualificationName} +
-
- ); + id: "actions", + header: () =>
Akcje
, + cell: ({ row }) => { + const userSummary = row.original; + + return ( +
+ +
+ ); + }, }, -}, ] \ No newline at end of file From b80a420a652cd0126f67d4ebaf24d20b9f7d15db Mon Sep 17 00:00:00 2001 From: Radek Date: Mon, 11 May 2026 11:26:07 +0200 Subject: [PATCH 6/7] pre authorize --- .../approval/ApprovalsController.java | 9 +++------ .../access/QualificationSecurity.java | 20 +++++++++++++++++++ .../QualificationManagementService.java | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/QualificationSecurity.java diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java index 457eac70..7204c89f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/approval/ApprovalsController.java @@ -74,12 +74,9 @@ public ResponseEntity> getUsersWithWaitingQua } @GetMapping("/qualifications/details") - @PreAuthorize("hasRole('LINEAR_MANAGER')") - public ResponseEntity> getQualificationRequestDetails( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam UUID userId - ) { - return ResponseEntity.ok(qualificationService.getPendingForUser(principal.userId(), userId)); + @PreAuthorize("hasRole('LINEAR_MANAGER') and @qualificationSecurity.isManagerForUser(principal.userId(), #userId)") + public ResponseEntity> getQualificationRequestDetails(@RequestParam UUID userId) { + return ResponseEntity.ok(qualificationService.getPendingForUser(userId)); } @PostMapping("/qualifications/bulk-update") diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/QualificationSecurity.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/QualificationSecurity.java new file mode 100644 index 00000000..96bf2f03 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/security/access/QualificationSecurity.java @@ -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); + } +} diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java index 48264c63..1442b2ab 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -75,7 +75,7 @@ public void updateRequests(UUID managerId, List requ } @Transactional(readOnly = true) - public List getPendingForUser(UUID managerId, UUID userId) { + public List getPendingForUser(UUID userId) { if (!userRepository.existsById(userId)) { throw new ApplicationException(ApiErrorCode.USER_NOT_FOUND); } From f70f3783aef8ec1f6b17286dbc6f7447d1f70a87 Mon Sep 17 00:00:00 2001 From: Radek Date: Mon, 11 May 2026 11:35:15 +0200 Subject: [PATCH 7/7] fix --- frontend/src/features/qualifications/qualifications.types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/qualifications/qualifications.types.ts b/frontend/src/features/qualifications/qualifications.types.ts index b9c49189..6f4aea8b 100644 --- a/frontend/src/features/qualifications/qualifications.types.ts +++ b/frontend/src/features/qualifications/qualifications.types.ts @@ -29,8 +29,7 @@ export interface PendingSkill { export const QualificationUpdateAction = { ACCEPT: 'ACCEPT', - REJECT: 'REJECT', - EMPTY: 'EMPTY', + REJECT: 'REJECT' } as const; export type QualificationUpdateAction = typeof QualificationUpdateAction[keyof typeof QualificationUpdateAction];