diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 875f80229..ef4b3131f 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1070,26 +1070,40 @@ def put(self, request: Request, pk: int) -> Response: class RecruitmentApplicationForRecruitmentPositionView(ModelViewSet): + # TODO: refactor this in ISSUE #1575 permission_classes = [IsAuthenticated] serializer_class = RecruitmentApplicationForGangSerializer queryset = RecruitmentApplication.objects.all() - # TODO: User should only be able to edit the fields that are allowed + # Define filter mappings as a class attribute + FILTER_MAPPINGS = { + 'unprocessed': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.NOT_SET}, + 'withdrawn': {'withdrawn': True}, + 'hardtoget': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.CALLED_AND_REJECTED}, + 'accepted': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.CALLED_AND_ACCEPTED}, + 'rejected': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.REJECTION}, + } def retrieve(self, request: Request, pk: int) -> Response: - """Returns a list of all the recruitments for the specified gang.""" + """ + Retrieve filtered applications for a specific recruitment position. + If no filter_type is provided, returns all applications. + Query Parameters: + - filter_type: string, one of ['unprocessed', 'withdrawn', 'hardtoget', 'accepted', 'rejected'] + """ position = get_object_or_404(RecruitmentPosition, id=pk) + applications = self.get_queryset().filter(recruitment_position=position) - applications = RecruitmentApplication.objects.filter( - recruitment_position=position, - ) - - # check permissions for each application - applications = get_objects_for_user(user=request.user, perms=['view_recruitmentapplication'], klass=applications) + filter_type = request.query_params.get('filter_type') + if filter_type: + filter_params = self.FILTER_MAPPINGS.get(filter_type) + if not filter_params: + return Response({'error': 'Invalid filter_type parameter'}, status=status.HTTP_400_BAD_REQUEST) + applications = applications.filter(**filter_params) serializer = self.get_serializer(applications, many=True) - return Response(serializer.data) + return Response(data=serializer.data) class ActiveRecruitmentPositionsView(ListAPIView): diff --git a/frontend/src/Components/Dropdown/index.ts b/frontend/src/Components/Dropdown/index.ts index c11eca394..73287634f 100644 --- a/frontend/src/Components/Dropdown/index.ts +++ b/frontend/src/Components/Dropdown/index.ts @@ -1,2 +1,2 @@ export { Dropdown } from './Dropdown'; -export type { DropdownProps } from './Dropdown'; +export type { DropdownProps, DropdownOption } from './Dropdown'; diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index c63343384..8ccd87139 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -55,7 +55,6 @@ export { ProgressBar } from './ProgressBar'; export { ProtectedRoute } from './ProtectedRoute'; export { PulseEffect } from './PulseEffect'; export { RadioButton } from './RadioButton'; -export { RecruitmentApplicantsStatus } from './RecruitmentApplicantsStatus'; export { RecruitmentWithoutInterviewTable } from './RecruitmentWithoutInterviewTable'; export { RootErrorBoundary } from './RootErrorBoundary'; export { SamfOutlet } from './SamfOutlet'; @@ -63,6 +62,7 @@ export { SamfundetLogo } from './SamfundetLogo'; export { SamfundetLogoSpinner } from './SamfundetLogoSpinner'; export { useScrollToTop } from './ScrollToTop'; export { Select } from './Select'; +export { SetInterviewManuallyModal } from './SetInterviewManually'; export { Skeleton } from './Skeleton'; export { SpinningBorder } from './SpinningBorder'; export { SplashHeaderBox } from './SplashHeaderBox'; @@ -89,7 +89,7 @@ export { Video } from './Video'; // Props export type { CheckboxProps } from './Checkbox'; -export type { DropdownProps } from './Dropdown'; +export type { DropdownOption, DropdownProps } from './Dropdown'; export type { ImagePickerProps } from './ImagePicker/ImagePicker'; export type { InputFieldProps } from './InputField'; export type { InputFileProps } from './InputFile'; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss index 9fc064c48..214a789a8 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss @@ -1,4 +1,3 @@ - .sub_container { margin-top: 1.5em; } diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 9d90f823c..8b4d23963 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,153 +1,173 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { Button, RecruitmentApplicantsStatus } from '~/Components'; -import { Text } from '~/Components/Text/Text'; -import { getRecruitmentApplicationsForGang, updateRecruitmentApplicationStateForPosition } from '~/api'; +import { Button, Text } from '~/Components'; +import { + getRecruitmentApplicationsForRecruitmentPosition, + getRecruitmentPosition, + updateRecruitmentApplicationStateForPosition, +} from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; -import { useTitle } from '~/hooks'; -import { STATUS } from '~/http_status_codes'; +import { useCustomNavigate, useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { lowerCapitalize } from '~/utils'; +import { dbT, lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; -import { ProcessedApplicants } from './components'; +import { ProcessedApplicants, RecruitmentApplicantsStatus } from './components'; + +// Define the possible states an application can be in within the recruitment process +// Applications flow through these states as they are processed by recruiters +// TODO add backend to fetch these. ISSUE #1575 +const APPLICATION_CATEGORY = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; +type ApplicationCategory = (typeof APPLICATION_CATEGORY)[number]; + +// Define query keys for React Query cache management +// These keys are used to organize and invalidate cached data efficiently +const queryKeys = { + applications: (positionId: string, type: ApplicationCategory) => ['applications', positionId, type] as const, + position: (positionId: string) => ['position', positionId] as const, +}; export function RecruitmentPositionOverviewPage() { - const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); - const [recruitmentApplicants, setRecruitmentApplicants] = useState([]); - const [withdrawnApplicants, setWithdrawnApplicants] = useState([]); - const [rejectedApplicants, setRejectedApplicants] = useState([]); - const [acceptedApplicants, setAcceptedApplicants] = useState([]); - const [hardtogetApplicants, setHardtogetApplicants] = useState([]); //Applicants that have been offered a position, but did not accept it + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const navigate = useCustomNavigate(); - const [recruiterStatuses, setRecruiterStatuses] = useState<[][]>([]); + // Track which application is currently being updated to show loading state + const [updatingId, setUpdatingId] = useState(null); - const [showSpinner, setShowSpinner] = useState(true); - const { t } = useTranslation(); - const load = useCallback(() => { - if (!recruitmentId || !gangId || !positionId) { - return; - } - getRecruitmentApplicationsForGang(gangId, recruitmentId) - .then((data) => { - setRecruitmentApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 0 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setWithdrawnApplicants( - data.data.filter( - (recruitmentApplicant) => - recruitmentApplicant.withdrawn && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setHardtogetApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 2 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setRejectedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setAcceptedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 1 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), + // Validate required URL parameters + if (!positionId || !recruitmentId || !gangId) { + toast.error(t(KEY.common_something_went_wrong)); + navigate({ url: -1 }); + return null; + } + + // Fetch details about the recruitment position + const positionQuery = useQuery({ + queryKey: queryKeys.position(positionId), + queryFn: () => getRecruitmentPosition(positionId), + }); + + // Fetch all applications for each possible application state in parallel + // This allows us to show all categories of applications simultaneously + const applicationQueries = useQueries({ + queries: APPLICATION_CATEGORY.map((type) => ({ + queryKey: queryKeys.applications(positionId, type), + queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId, type), + enabled: !!positionId, + })), + }); + + const isLoading = applicationQueries.some((query) => query.isLoading) || positionQuery.isLoading; + + // Organize application data by category for easier access + const applications = Object.fromEntries( + applicationQueries.map((query, index) => [APPLICATION_CATEGORY[index], query.data || []]), + ) as Record; + + // Handle updating application states with optimistic updates + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => + updateRecruitmentApplicationStateForPosition(id, data), + // Optimistically update the UI before the server responds + onMutate: async ({ id, data }) => { + // Cancel any outgoing refetches to avoid overwriting our optimistic update + await queryClient.cancelQueries(); + + // Store the current state to roll back if the mutation fails + const previousData: Partial> = {}; + + // Save current state for all application categories + for (const type of APPLICATION_CATEGORY) { + const queryData = queryClient.getQueryData( + queryKeys.applications(positionId, type), ); - setShowSpinner(false); - }) - .catch((data) => { - if (data.status === STATUS.HTTP_404_NOT_FOUND) { - navigate(ROUTES.frontend.not_found, { replace: true }); + if (queryData) { + previousData[type] = queryData; } - toast.error(t(KEY.common_something_went_wrong)); - }); - }, [recruitmentId, gangId, positionId, navigate, t]); + } + // Optimistically update all relevant queries + for (const type of APPLICATION_CATEGORY) { + queryClient.setQueryData(queryKeys.applications(positionId, type), (old) => { + if (!old) return []; + return old.map((application) => (application.id === id ? { ...application, ...data } : application)); + }); + } - useEffect(() => { - load(); - }, [load]); + return { previousData }; + }, + // If mutation fails, roll back to the previous state + onError: (_, __, context) => { + if (context?.previousData) { + for (const type of APPLICATION_CATEGORY) { + const previousTypeData = context.previousData[type]; + if (previousTypeData) { + queryClient.setQueryData(queryKeys.applications(positionId, type), previousTypeData); + } + } + } + toast.error(t(KEY.common_something_went_wrong)); + }, + // On successful mutation, invalidate and refetch all application queries + onSuccess: () => { + for (const type of APPLICATION_CATEGORY) { + queryClient.invalidateQueries({ + queryKey: queryKeys.applications(positionId, type), + }); + } + }, + }); + // Wrapper function to update application state with loading indicator const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { - positionId && - updateRecruitmentApplicationStateForPosition(id, data) - .then((data) => { - setRecruitmentApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 0 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setWithdrawnApplicants( - data.data.filter( - (recruitmentApplicant) => - recruitmentApplicant.withdrawn && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setHardtogetApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 2 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setRejectedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setAcceptedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 1 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setShowSpinner(false); - }) - .catch((data) => { - toast.error(t(KEY.common_something_went_wrong)); - console.error(data); - }); + setUpdatingId(id); + updateMutation.mutate( + { id, data }, + { + onSettled: () => { + setUpdatingId(null); + }, + }, + ); }; + // Define sections for different application categories with their respective texts + const applicationSections = [ + { + type: 'accepted' as const, + title: KEY.recruitment_accepted_applications, + helpText: KEY.recruitment_accepted_applications_help_text, + emptyText: KEY.recruitment_accepted_applications_empty_text, + }, + { + type: 'rejected' as const, + title: KEY.recruitment_rejected_applications, + helpText: KEY.recruitment_rejected_applications_help_text, + emptyText: KEY.recruitment_rejected_applications_empty_text, + }, + { + type: 'hardtoget' as const, + title: KEY.recruitment_hardtoget_applications, + helpText: KEY.recruitment_hardtoget_applications_help_text, + emptyText: KEY.recruitment_hardtoget_applications_empty_text, + }, + { + type: 'withdrawn' as const, + title: KEY.recruitment_withdrawn_applications, + helpText: '', + emptyText: KEY.recruitment_withdrawn_applications_empty_text, + }, + ]; + const title = t(KEY.recruitment_administrate_applications); useTitle(title); - - const backendUrl = reverse({ - pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change, - urlParams: { - objectId: positionId, - }, - }); + const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionQuery.data ? dbT(positionQuery.data?.data, 'name') : 'N/A'}`; const header = (