Skip to content

Commit

Permalink
VKT(Backend & Frontend): move enrollment to other exam event modal
Browse files Browse the repository at this point in the history
  • Loading branch information
jrkkp committed Dec 17, 2024
1 parent 2ef02a0 commit 419a437
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.springframework.http.MediaType.ALL_VALUE;

import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentContactRequestDTO;
import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentMoveDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentHistoryDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentUpdateDTO;
Expand Down Expand Up @@ -91,6 +92,15 @@ public List<ExaminerEnrollmentAppointmentHistoryDTO> getEnrollmentAppointmentHis
return examinerEnrollmentService.getEnrollmentAppointmentHistory(oid, enrollmentAppointmentId);
}

@PutMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/move")
@Operation(tags = TAG_ENROLLMENT, summary = "Move enrollment to another exam event")
public ExaminerEnrollmentAppointmentDTO move(
@PathVariable final String oid,
@RequestBody @Valid final ClerkEnrollmentMoveDTO dto
) {
return examinerEnrollmentService.move(oid, dto);
}

@PostMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/sendAuthLink", consumes = ALL_VALUE)
@Operation(tags = TAG_ENROLLMENT, summary = "Send enrollment appointment auth link")
public ExaminerEnrollmentAppointmentDTO sendEnrollmentAppointmentLink(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fi.oph.vkt.service;

import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentContactRequestDTO;
import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentMoveDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentHistoryDTO;
import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentUpdateDTO;
Expand Down Expand Up @@ -296,4 +297,24 @@ public List<ExaminerEnrollmentAppointmentHistoryDTO> getEnrollmentAppointmentHis
.map(ClerkEnrollmentUtil::createClerkEnrollmentAppointmentHistoryDTO)
.toList();
}

@Transactional
public ExaminerEnrollmentAppointmentDTO move(final String oid, final ClerkEnrollmentMoveDTO dto) {
final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(dto.id());
final ExaminerExamEvent newExaminerExamEvent = examinerExamEventRepository.getReferenceById(dto.toExamEventId());
final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");

enrollmentAppointment.assertVersion(dto.version());
checkExaminerOid(enrollmentAppointment, oid);

if (enrollmentAppointment.getExaminer().getId() != newExaminerExamEvent.getExaminer().getId()) {
throw new APIException(APIExceptionType.EXAMINER_NEW_EXAM_EVENT_MISMATCH);
}

enrollmentAppointment.setExaminerExamEvent(newExaminerExamEvent);

enrollmentAppointmentRepository.flush();

return ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(enrollmentAppointment, baseUrlAPI);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public enum APIExceptionType {
EXAMINER_APPOINTMENT_ID_MISMATCH,
AUTH_HASH_EXPIRED,
ONR_SAVE_EXCEPTION,
ONR_PERSON_INSERT_EXCEPTION;
ONR_PERSON_INSERT_EXCEPTION,
EXAMINER_NEW_EXAM_EVENT_MISMATCH;

public String getCode() {
final StringBuilder codeBuilder = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { CustomButton } from 'shared/components';
import { CustomButton, CustomModal } from 'shared/components';
import {
APIResponseStatus,
Color,
Expand All @@ -11,6 +11,7 @@ import { useDialog, useToast } from 'shared/hooks';
import { StringUtils } from 'shared/utils';

import { ClerkEnrollmentAppointmentDetailsFields } from 'components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetailsFields';
import { MoveModal } from 'components/clerkEnrollment/appointment/MoveModal';
import { ControlButtons } from 'components/clerkEnrollment/overview/ControlButtons';
import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
import { useAppDispatch, useAppSelector } from 'configs/redux';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const ClerkEnrollmentAppointmentDetails = ({
ClerkEnrollmentAppointment | undefined
>(enrollment);
const [hasLocalChanges, setHasLocalChanges] = useState(false);
const [isMoveModalOpen, setIsOpenModalOpen] = useState(false);
const [currentUIMode, setCurrentUIMode] = useState(
editMode ? UIMode.Edit : UIMode.View,
);
Expand Down Expand Up @@ -168,6 +170,9 @@ export const ClerkEnrollmentAppointmentDetails = ({
setCurrentUIMode(UIMode.Edit);
};

const handleMoveButtonClick = () => setIsOpenModalOpen(true);
const closeMoveModal = () => setIsOpenModalOpen(false);

const openCancelDialog = () => {
showDialog({
title: translateCommon('cancelUpdateDialog.header'),
Expand Down Expand Up @@ -222,6 +227,19 @@ export const ClerkEnrollmentAppointmentDetails = ({

return (
<>
<CustomModal
data-testid="clerk-enrollment-appointment-details__move-modal"
open={isMoveModalOpen}
onCloseModal={closeMoveModal}
aria-labelledby="modal-title"
modalTitle={t('moveModal.title')}
>
<MoveModal
oid={oid}
enrollment={enrollmentDetails}
onCancel={closeMoveModal}
/>
</CustomModal>
<ClerkEnrollmentAppointmentDetailsFields
showFieldErrorBeforeChange={false}
enrollment={enrollmentDetails}
Expand All @@ -234,6 +252,7 @@ export const ClerkEnrollmentAppointmentDetails = ({
<ControlButtons
onCancel={handleCancelButtonClick}
onEdit={handleEditButtonClick}
onMove={handleMoveButtonClick}
onSave={handleSaveButtonClick}
isViewMode={isViewMode}
hasRequiredDetails={hasRequiredDetails}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ComboBox,
CustomButton,
H3,
LoadingProgressIndicator,
} from 'shared/components';
import {
APIResponseStatus,
Color,
Severity,
TextFieldVariant,
Variant,
} from 'shared/enums';
import { useToast } from 'shared/hooks';
import { ComboBoxOption } from 'shared/interfaces';

import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
import { useAppDispatch, useAppSelector } from 'configs/redux';
import { AppRoutes } from 'enums/app';
import { useExamEventDescription } from 'hooks/useExamEventDescription';
import { ClerkEnrollmentAppointment } from 'interfaces/clerkEnrollment';
import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
import {
moveEnrollment,
resetMoveEnrollment,
} from 'redux/reducers/clerkEnrollmentAppointment';
import {
loadExaminerExamEvents,
resetClerkEnrollmentContactRequestToInitialState,
} from 'redux/reducers/clerkEnrollmentContactRequest';
import { resetExaminerDetailsToInitialState } from 'redux/reducers/examinerDetails';
import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
import { clerkEnrollmentContactRequestSelector } from 'redux/selectors/clerkEnrollmentContactRequest';

interface MoveModalProps {
enrollment: ClerkEnrollmentAppointment;
onCancel: () => void;
oid: string;
}

export const MoveModal: FC<MoveModalProps> = ({
enrollment,
onCancel,
oid,
}) => {
const { t } = useClerkTranslation({
keyPrefix: 'vkt.component.clerkEnrollmentDetails.moveModal',
});
const translateCommon = useCommonTranslation();
const describeExamEvent = useExamEventDescription();

const [selectedExamEventOption, setSelectedExamEventOption] =
useState<ComboBoxOption | null>(null);

const dispatch = useAppDispatch();
const navigate = useNavigate();
const { showToast } = useToast();

const { moveStatus } = useAppSelector(clerkEnrollmentAppointmentSelector);
const { examEventsStatus, examEvents } = useAppSelector(
clerkEnrollmentContactRequestSelector,
);
const examEvent = enrollment.examEvent;

useEffect(() => {
if (examEventsStatus === APIResponseStatus.NotStarted) {
dispatch(loadExaminerExamEvents(oid));
}
}, [dispatch, examEventsStatus, oid]);

useEffect(() => {
if (moveStatus === APIResponseStatus.Success) {
showToast({
severity: Severity.Success,
description: t('successToast'),
});
dispatch(resetMoveEnrollment());
dispatch(resetClerkEnrollmentContactRequestToInitialState());
dispatch(resetExaminerDetailsToInitialState());
navigate(AppRoutes.ExaminerHomePage.replace(':oid', oid), {
replace: true,
});
}
}, [dispatch, navigate, showToast, oid, t, moveStatus]);

const isLoading = moveStatus === APIResponseStatus.InProgress;

const getComboBoxOption = (e: ExaminerExamEvent) => {
return {
label: describeExamEvent(e),
value: `${e.id}`,
};
};

const selectableExamEventOptions = examEvents
.filter(
(e: ExaminerExamEvent) =>
examEvent && e.language === examEvent.language && e.id !== examEvent.id,
)
.reverse()
.map(getComboBoxOption);

const handleExamEventOptionChange = (value?: string) => {
if (value) {
const selected = selectableExamEventOptions.filter(
(v: ComboBoxOption) => v.value === value,
);
if (selected.length > 0) {
setSelectedExamEventOption(selected[0]);
}
} else {
setSelectedExamEventOption(null);
}
};

const handleMoveButtonClick = () => {
if (selectedExamEventOption) {
dispatch(
moveEnrollment({
id: enrollment.id,
version: enrollment.version,
toExamEventId: Number(selectedExamEventOption.value),
oid: oid,
}),
);
}
};

return (
<div className="examiner-enrollment-details__move-modal">
<div className="rows gapped-xs">
<H3>{t('newExamEvent')}</H3>
<ComboBox
className="examiner-enrollment-details__move-modal__combobox"
data-testid="examiner-enrollment-details__move-modal__exam-date"
autoHighlight
label={translateCommon('choose')}
values={selectableExamEventOptions}
value={selectedExamEventOption}
variant={TextFieldVariant.Outlined}
onChange={handleExamEventOptionChange}
/>
</div>
<div className="columns gapped margin-top-lg flex-end">
<CustomButton
disabled={isLoading}
data-testid="examiner-enrollment-details__move-modal__cancel-button"
className="margin-right-xs"
onClick={onCancel}
variant={Variant.Text}
color={Color.Secondary}
>
{translateCommon('cancel')}
</CustomButton>
<LoadingProgressIndicator isLoading={isLoading}>
<CustomButton
data-testid="examiner-enrollment-details__move-modal__save-button"
variant={Variant.Contained}
color={Color.Secondary}
onClick={handleMoveButtonClick}
disabled={!selectedExamEventOption || isLoading}
>
{t('move')}
</CustomButton>
</LoadingProgressIndicator>
</div>
</div>
);
};
4 changes: 4 additions & 0 deletions frontend/packages/vkt/src/interfaces/clerkEnrollment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface ClerkEnrollmentStatusChange extends WithId, WithVersion {
newStatus: EnrollmentStatus;
}

export interface ClerkEnrollmentAppointmentMove extends ClerkEnrollmentMove {
oid: string;
}

export interface ClerkEnrollmentMove extends WithId, WithVersion {
toExamEventId: number;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ClerkEnrollmentAppointment,
ClerkEnrollmentAppointmentGrades,
ClerkEnrollmentAppointmentHistory,
ClerkEnrollmentAppointmentMove,
} from 'interfaces/clerkEnrollment';

interface ClerkEnrollmentAppointmentState {
Expand All @@ -18,6 +19,7 @@ interface ClerkEnrollmentAppointmentState {
gradesSaveStatus: APIResponseStatus;
sendLinkStatus: APIResponseStatus;
grades?: ClerkEnrollmentAppointmentGrades;
moveStatus: APIResponseStatus;
}

const initialState: ClerkEnrollmentAppointmentState = {
Expand All @@ -28,6 +30,7 @@ const initialState: ClerkEnrollmentAppointmentState = {
gradesStatus: APIResponseStatus.NotStarted,
gradesSaveStatus: APIResponseStatus.NotStarted,
sendLinkStatus: APIResponseStatus.NotStarted,
moveStatus: APIResponseStatus.NotStarted,
grades: {
version: 0,
speakingPartialExam: {
Expand Down Expand Up @@ -176,12 +179,31 @@ const clerkEnrollmentAppointmentSlice = createSlice({
state.enrollmentHistory = action.payload;
state.historyStatus = APIResponseStatus.Success;
},
moveEnrollment(
state,
_action: PayloadAction<ClerkEnrollmentAppointmentMove>,
) {
state.moveStatus = APIResponseStatus.InProgress;
},
moveEnrollmentSucceeded(state) {
state.moveStatus = APIResponseStatus.Success;
},
rejectMoveEnrollment(state) {
state.moveStatus = APIResponseStatus.Error;
},
resetMoveEnrollment(state) {
state.moveStatus = initialState.moveStatus;
},
},
});

export const clerkEnrollmentAppointmentReducer =
clerkEnrollmentAppointmentSlice.reducer;
export const {
moveEnrollment,
moveEnrollmentSucceeded,
rejectMoveEnrollment,
resetMoveEnrollment,
storeClerkEnrollmentAppointmentUpdate,
rejectClerkEnrollmentAppointment,
storeClerkEnrollmentAppointment,
Expand Down
Loading

0 comments on commit 419a437

Please sign in to comment.