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..7204c89f 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,29 @@ 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') and @qualificationSecurity.isManagerForUser(principal.userId(), #userId)") + public ResponseEntity> getQualificationRequestDetails(@RequestParam UUID userId) { + return ResponseEntity.ok(qualificationService.getPendingForUser(userId)); + } + + @PostMapping("/qualifications/bulk-update") + @PreAuthorize("hasRole('LINEAR_MANAGER')") + 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..1dde4be2 --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/dto/approvals/QualificationUpdateAction.java @@ -0,0 +1,6 @@ +package pl.edu.agh.project_manager.controller.dto.approvals; + +public enum QualificationUpdateAction { + ACCEPT, + REJECT +} 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/entity/user/Qualification.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/Qualification.java index b07c0228..658967bc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/Qualification.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/entity/user/Qualification.java @@ -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; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/exception/ApiErrorCode.java index 22d6412a..17e6bcd6 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,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"), 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..520f4a61 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,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 { @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/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 new file mode 100644 index 00000000..1442b2ab --- /dev/null +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -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 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() + .collect(Collectors.toMap(QualificationUpdateRequest::qualificationId, r -> r)); + + 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.QUALIFICATION_OWNER_NOT_SUBORDINATE); + } + + for (Qualification qualification : allowedQualifications) { + validateWaitingStatus(qualification); + + var action = activeRequestsMap.get(qualification.getId()).action(); + + switch (action) { + case QualificationUpdateAction.ACCEPT -> qualification.accept(); + case QualificationUpdateAction.REJECT -> qualification.reject(); + } + } + } + + @Transactional(readOnly = true) + public List 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); + } + } +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 5d25a549..08a2d107 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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; \ 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..31230902 --- /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>({}); + + const pendingPayload: QualificationUpdateRequest[] = Object.entries(decisions).map( + ([qualificationId, action]) => ({ + qualificationId, + action, + }) + ); + + const hasChanges = pendingPayload.length > 0; + + const handleActionChange = (qualificationId: string, action: QualificationUpdateAction) => { + setDecisions((prev) => { + const newState = { ...prev }; + if (prev[qualificationId] === action) { + delete newState[qualificationId]; + } else { + newState[qualificationId] = action; + } + return newState; + }); + }; + + const handleSave = () => { + if (!hasChanges) { + onClose(); + return; + } + + updateQualifications(pendingPayload, { + onSuccess: () => onClose(), + }); + }; + + if (isLoading) { + return ( +
+ +

Pobieranie szczegółów...

+
+ ); + } + + 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]; + + 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..131832ff --- /dev/null +++ b/frontend/src/features/qualifications/qualifications.columns.tsx @@ -0,0 +1,63 @@ +"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' : + (count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20)) + ? 'WNIOSKI' + : 'WNIOSKÓW' + } + + ); + }, + }, + { + 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..6f4aea8b 100644 --- a/frontend/src/features/qualifications/qualifications.types.ts +++ b/frontend/src/features/qualifications/qualifications.types.ts @@ -26,3 +26,27 @@ export interface PendingSkill { name: string; id?: string; } + +export const QualificationUpdateAction = { + ACCEPT: 'ACCEPT', + REJECT: 'REJECT' +} 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..c2b4736f 100644 --- a/frontend/src/pages/EmployeeAssignmentsPage.tsx +++ b/frontend/src/pages/EmployeeAssignmentsPage.tsx @@ -5,13 +5,13 @@ 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(); const { rejectRequest } = useEmployeeAssignmentsActions(); const [selectedAssignment, setSelectedAssignment] = useState(null); - const isModalOpen = !!selectedAssignment; const handleReject = useCallback((assignment: EmployeeAssignment) => { rejectRequest(assignment.id); @@ -25,25 +25,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 +44,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 56ae01a0..81469452 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'; import { CreateProjectGroupPage } from '@/pages/CreateProjectGroupPage'; export const AppRoutes = () => { @@ -36,7 +37,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 6515ae4a..325092e5 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 68bdfe1a..c81f7345 100644 --- a/frontend/src/routes/paths.ts +++ b/frontend/src/routes/paths.ts @@ -13,10 +13,11 @@ 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`, - CREATE_PROJECT_GROUP: `/create-project-group`, - PROJECT: (id: string) => `project/${id}` + PROJECT: (id: string) => `project/${id}`, + QUALIFICATION_REQUESTS: '/qualification-requests', + CREATE_PROJECT_GROUP: `/create-project-group` } as const; \ No newline at end of file