diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index 83dbd473b..d313a38ac 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -11,6 +11,7 @@ import type { ExamIncident, CreateIncidentPayload, ResolveIncidentPayload, + RegradeOutcome, } from '../types/exam'; const examApi = baseApi.injectEndpoints({ @@ -20,7 +21,9 @@ const examApi = baseApi.injectEndpoints({ providesTags: ['Api'], }), getExam: builder.query({ - query: (id) => `/exams/${id}?i=questions`, + // Nested relation so CHOICE questions carry their options here too — + // needed to print real answer choices instead of blank A/B/C/D lines. + query: (id) => `/exams/${id}?i=questions,questions.options`, providesTags: ['Api'], }), createExam: builder.mutation({ @@ -90,6 +93,14 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** Staff-triggered re-run of auto-grading for one finalized attempt. */ + regradeAttempt: builder.mutation({ + query: (attemptId) => ({ + url: `/exam-attempts/${attemptId}/regrade`, + method: 'POST', + }), + invalidatesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -107,4 +118,5 @@ export const { useGetExamIncidentsQuery, useRecordIncidentMutation, useResolveIncidentMutation, + useRegradeAttemptMutation, } = examApi; diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx index 421a39c02..0884a3db6 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -1,5 +1,5 @@ -import { Badge, Button, Text } from '@mantine/core'; -import { IconUserCheck } from '@tabler/icons-react'; +import { ActionIcon, Badge, Menu, Text } from '@mantine/core'; +import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -25,7 +25,11 @@ export const candidateName = (registration: ExamRegistration) => export function examCandidateColumns( t: TFunction, - handlers: { onRecord: (registration: ExamRegistration) => void }, + handlers: { + onRecord: (registration: ExamRegistration) => void; + onRegrade: (registration: ExamRegistration) => void; + regrading?: string | null; + }, ): AdvancedColumn[] { return [ { @@ -78,21 +82,51 @@ export function examCandidateColumns( header: '', label: t('exam.candidates.record'), align: 'right', - cell: ({ row }) => ( - - - - ), + cell: ({ row }) => { + const attemptStatus = row.original.attempt?.status; + const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED'; + return ( + + + + + + + + + } + onClick={() => handlers.onRecord(row.original)} + > + {t('exam.candidates.record')} + + + {canRegrade && ( + + } + onClick={() => handlers.onRegrade(row.original)} + > + {t('exam.candidates.regrade')} + + + )} + + + ); + }, }, ]; } diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx index e02dad17c..4c11d1b7b 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx @@ -18,6 +18,7 @@ import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamRegistrationsQuery, useRecordAttendanceMutation, + useRegradeAttemptMutation, } from '../../api/exam-api'; import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; import { candidateName, examCandidateColumns } from './columns'; @@ -43,11 +44,32 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { const { t } = useTranslation(); const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId); const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); + const [regradeAttempt] = useRegradeAttemptMutation(); + const [regrading, setRegrading] = useState(null); const [target, setTarget] = useState(null); const [status, setStatus] = useState('PRESENT'); const [remark, setRemark] = useState(''); const table = useServerTable(); + const regrade = async (registration: ExamRegistration) => { + const attemptId = registration.attempt?.id; + if (!attemptId) return; + setRegrading(attemptId); + try { + const outcome = await regradeAttempt(attemptId).unwrap(); + if (outcome.graded) { + notify.success(t('exam.candidates.regraded')); + } else { + notify.error(t('exam.candidates.regradeNotEligible', { reason: outcome.reason })); + } + refetch(); + } catch (error) { + notify.error(extractErrorMessage(error, t('exam.candidates.regradeError'))); + } finally { + setRegrading(null); + } + }; + const startRecording = (registration: ExamRegistration) => { setTarget(registration); setStatus( @@ -96,7 +118,11 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { ) : ( { - const total = (exam.questions ?? []).reduce( - (s, q) => s + Number(q.points), - 0, - ); - if (total < Number(exam.cuttingPoint)) { + // The reachable max depends on the evaluation method, not the raw point + // sum — mirrors RecordResultModal's grading math so "can this paper pass" + // means the same thing here as it does at marking time. Cutting point can + // be raised after the paper was assembled (edit modal, no re-check on + // save), so this still needs to run even though assignment now enforces + // it too. + const questions = exam.questions ?? []; + const total = questions.reduce((s, q) => s + Number(q.points), 0); + const reachableMax = + exam.evaluationMethod === 'AVERAGE' + ? questions.length + ? total / questions.length + : 0 + : exam.evaluationMethod === 'PERCENTAGE' + ? 100 + : total; + if (reachableMax < Number(exam.cuttingPoint)) { notify.error( - `Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`, + `This paper cannot reach the passing mark under its ${EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} evaluation (max ${reachableMax}, pass mark ${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`, ); return; } @@ -232,7 +254,23 @@ export function ExamDetailPage() {

${titleStr}

${descStr ? `

${descStr}

` : ""} ${q.form === "ESSAY" ? '
'.repeat(3) : ""} - ${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `

${l}

`).join("") : ""} + ${ + q.form === "CHOICE" + ? q.options && q.options.length + ? q.options + .slice() + .sort((a, b) => a.order - b.order) + .map( + (o, oi) => + `

${String.fromCharCode(65 + oi)}. ${o.text[locale] || o.text.en}

`, + ) + .join("") + // No options on record (legacy question, or options relation + // wasn't loaded) — fall back to blank lines rather than + // printing nothing. + : ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `

${l}

`).join("") + : "" + } `; }) .join(""); @@ -247,7 +285,20 @@ export function ExamDetailPage() { .header p { margin: 2px 0; font-size: 13px; color: #555; } .directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; } .directions strong { display: block; margin-bottom: 4px; } - @media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } } + .footer { margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center; } + /* Pinned to the bottom of every printed page (not just after the + last question) — @page's bottom margin leaves room for it so it + never overlaps question text on the last page. */ + @media print { + @page { margin: 20mm 20mm 28mm 20mm; } + /* @page's margin already insets content from the physical page + edge — body's own 40px padding (needed on-screen, for the + preview tab before printing) would double up with it here, + wasting real page height on every side and fitting noticeably + fewer questions per page than the paper actually has room for. */ + body { -webkit-print-color-adjust: exact; padding: 0; max-width: none; } + .footer { position: fixed; bottom: 0; left: 0; right: 0; margin-top: 0; } + }
${logoBase64 ? `` : ""} @@ -258,7 +309,7 @@ export function ExamDetailPage() {
${exam.direction?.[locale] ? `
Directions: ${exam.direction[locale]}
` : ""} ${qHtml} -
+ diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx index ba902be9d..ce6377a36 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx @@ -1,5 +1,11 @@ -import { ActionIcon, Group } from "@mantine/core"; -import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react"; +import { ActionIcon, Menu } from "@mantine/core"; +import { + IconDetails, + IconDotsVertical, + IconEdit, + IconTrash, + IconToggleRight, +} from "@tabler/icons-react"; import type { TFunction } from "i18next"; import type { AdvancedColumn } from "@ema-platform/ui"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; @@ -11,40 +17,55 @@ export function examActionsColumn( onEdit: (exam: Exam) => void; onDelete: (exam: Exam) => void; onDetails: (exam: Exam) => void; + onOpenStatusChange: (exam: Exam) => void; + changingStatusId?: string | null; }, ): AdvancedColumn { return { - header: t("exam.columns.actions"), + header: t("exam.columns.actions", "Actions"), align: "right", cell: ({ row }) => ( - - + + handlers.onEdit(row.original)} + loading={handlers.changingStatusId === row.original.id} > - + - handlers.onDelete(row.original)} + + + } + onClick={() => handlers.onDetails(row.original)} > - - - - handlers.onDetails(row.original)} - > - - - + {t("exam.action.details", "Details")} + + + } + onClick={() => handlers.onEdit(row.original)} + > + {t("exam.action.edit", "Edit")} + + } + onClick={() => handlers.onOpenStatusChange(row.original)} + > + {t("exam.form.status")} + + } + onClick={() => handlers.onDelete(row.original)} + > + {t("exam.action.delete", "Delete")} + + + + ), }; } diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index c34e5975b..a7ba0d5de 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -77,21 +77,23 @@ function ExamForm({ editing?.cuttingPoint ?? 0, ); const [status, setStatus] = useState(editing?.status ?? null); + const [activeTab, setActiveTab] = useState("basic"); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if ( - !certificationId || - !titleEn || - !titleAm || - !date || - !type || - !form || - !venue || - !adminMethod || - !evalMethod - ) { - notify.error("Please fill all required fields"); + if (!certificationId || !titleEn || !titleAm || !date || !venue) { + setActiveTab("basic"); + notify.error(t("exam.form.fillRequiredBasic")); + return; + } + if ((directionEn || directionAm) && !(directionEn && directionAm)) { + setActiveTab("basic"); + notify.error(t("exam.form.directionBothLanguages")); + return; + } + if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) { + setActiveTab("settings"); + notify.error(t("exam.form.fillRequiredSettings")); return; } onSubmit( @@ -121,7 +123,7 @@ function ExamForm({ return (
- + }> {t("exam.form.basicInfo")} @@ -252,6 +254,12 @@ function ExamForm({ onChange={setForm} size="sm" required + disabled={adminMethod === "ONLINE"} + description={ + adminMethod === "ONLINE" + ? t("exam.form.onlineChoiceOnlyHint") + : undefined + } /> setPendingStatus(value as Exam["status"])} + size="sm" + /> + + + + + + ); } diff --git a/apps/backoffice/src/app/features/exam/types/exam.ts b/apps/backoffice/src/app/features/exam/types/exam.ts index d1a3d3587..1c25cfd7d 100644 --- a/apps/backoffice/src/app/features/exam/types/exam.ts +++ b/apps/backoffice/src/app/features/exam/types/exam.ts @@ -15,11 +15,19 @@ export type ExamStatus = | "POSTPONED" | "PUBLISHED"; +/** Only populated when the exam is fetched with `?i=questions,questions.options`. */ +export interface QuestionOptionBrief { + id: string; + text: LocalePair; + order: number; +} + export interface QuestionBrief { id: string; title: LocalePair; form: QuestionForm; points: number; + options?: QuestionOptionBrief[]; } export interface Exam { @@ -118,8 +126,14 @@ export interface ExamRegistration { lastName: string | null; seafarerNumber: string | null; }; + /** The candidate's online sitting, when one has been started. */ + attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null; } +export type RegradeOutcome = + | { graded: true; resultId: string } + | { graded: false; reason: string }; + export interface RecordAttendancePayload { registrationId: string; status: AttendanceStatus; diff --git a/apps/backoffice/src/app/features/question/api/question-api.ts b/apps/backoffice/src/app/features/question/api/question-api.ts index a118af749..d84e801f2 100644 --- a/apps/backoffice/src/app/features/question/api/question-api.ts +++ b/apps/backoffice/src/app/features/question/api/question-api.ts @@ -1,10 +1,12 @@ import { baseApi } from '@ema-platform/api'; import type { Question, + QuestionOption, ListResponse, CreateQuestionPayload, UpdateQuestionPayload, ReviewQuestionPayload, + SetQuestionOptionsPayload, } from '../types/question'; const questionApi = baseApi.injectEndpoints({ @@ -17,6 +19,11 @@ const questionApi = baseApi.injectEndpoints({ query: (id) => `/questions/${id}`, providesTags: ['Api'], }), + /** Same question, with `options` populated — the MCQ authoring editor. */ + getQuestionWithOptions: builder.query({ + query: (id) => `/questions/${id}?i=options`, + providesTags: ['Api'], + }), createQuestion: builder.mutation({ query: (body) => ({ url: '/questions', method: 'POST', body }), invalidatesTags: ['Api'], @@ -47,6 +54,15 @@ const questionApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** Full replace of a CHOICE question's options + correct-answer set (Phase 2). */ + setQuestionOptions: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/questions/${id}/options`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), }), overrideExisting: false, }); @@ -54,9 +70,11 @@ const questionApi = baseApi.injectEndpoints({ export const { useGetQuestionsQuery, useGetQuestionQuery, + useGetQuestionWithOptionsQuery, useCreateQuestionMutation, useUpdateQuestionMutation, useDeleteQuestionMutation, useSubmitQuestionMutation, useReviewQuestionMutation, + useSetQuestionOptionsMutation, } = questionApi; diff --git a/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx b/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx new file mode 100644 index 000000000..3a464cff2 --- /dev/null +++ b/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react'; +import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'; +import { notify, useErrorHandler } from '@ema-platform/ui'; +import type { BilingualValue } from '@ema-platform/ui'; +import { + useGetQuestionWithOptionsQuery, + useSetQuestionOptionsMutation, +} from '../api/question-api'; + +interface DraftOption { + text: BilingualValue; + isCorrect: boolean; +} + +/** + * MCQ options + correct-answer editor for a CHOICE-form question (Phase 2). + * + * Only reachable while editing an already-created question — options attach + * to a question id, matching the backend's `PUT /questions/:id/options` + * full-replace endpoint. Nothing here is ever shown to a candidate; this is + * the authoring side only. + */ +export function QuestionOptionsEditor({ questionId }: { questionId: string }) { + const { t } = useTranslation(); + const { handleError } = useErrorHandler(); + const { data: question, isFetching } = useGetQuestionWithOptionsQuery(questionId); + const [setOptions, { isLoading: isSaving }] = useSetQuestionOptionsMutation(); + const [draft, setDraft] = useState([]); + + useEffect(() => { + if (!question) return; + const existing = question.options ?? []; + setDraft( + existing.length + ? existing + .slice() + .sort((a, b) => a.order - b.order) + .map((o) => ({ text: o.text, isCorrect: false })) + : [ + { text: { en: '', am: '' }, isCorrect: false }, + { text: { en: '', am: '' }, isCorrect: false }, + ], + ); + // isCorrect never comes back from the API by design — an examiner + // re-editing options re-marks the correct one(s) rather than us + // pretending to know what they were. + }, [question]); + + const updateField = (index: number, lang: keyof BilingualValue, value: string) => { + setDraft((prev) => + prev.map((o, i) => (i === index ? { ...o, text: { ...o.text, [lang]: value } } : o)), + ); + }; + + const toggleCorrect = (index: number) => { + setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, isCorrect: !o.isCorrect } : o))); + }; + + const addOption = () => { + setDraft((prev) => [...prev, { text: { en: '', am: '' }, isCorrect: false }]); + }; + + const removeOption = (index: number) => { + setDraft((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + if (draft.length < 2) { + notify.error(t('question.options.needAtLeastTwo')); + return; + } + if (!draft.some((o) => o.isCorrect)) { + notify.error(t('question.options.needOneCorrect')); + return; + } + if (draft.some((o) => !o.text.en.trim() || !o.text.am.trim())) { + notify.error(t('question.options.textRequired')); + return; + } + try { + await setOptions({ id: questionId, options: draft }).unwrap(); + notify.success(t('question.options.saved')); + } catch (e) { + handleError(e); + } + }; + + if (isFetching) return ; + + return ( + + } color="blue" variant="light"> + {t('question.options.hint')} + + {draft.map((option, index) => ( + + + + updateField(index, 'en', e.currentTarget.value)} + size="sm" + required + /> + updateField(index, 'am', e.currentTarget.value)} + size="sm" + required + /> + + toggleCorrect(index)} + /> + removeOption(index)} + > + + + + ))} + + + + + {t('question.options.replaceNotice')} + + ); +} diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx index 05f1b02fb..7226fc109 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx @@ -1,5 +1,11 @@ -import { ActionIcon, Button, Group } from '@mantine/core'; -import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react'; +import { ActionIcon, Menu } from '@mantine/core'; +import { + IconDotsVertical, + IconEdit, + IconGavel, + IconSend, + IconTrash, +} from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -21,52 +27,64 @@ export function questionActionsColumn( cell: ({ row }) => { const q = row.original; return ( - - {(q.status === 'DRAFT' || q.status === 'REJECTED') && ( + + + + + + + + {(q.status === 'DRAFT' || q.status === 'REJECTED') && ( + + } + onClick={() => handlers.onSubmitForApproval(q)} + > + {t('question.qc.submit')} + + + )} + {q.status === 'PENDING_APPROVAL' && ( + + handlers.onReview(q, 'APPROVED')}> + {t('question.qc.approve')} + + handlers.onReview(q, 'REJECTED')}> + {t('question.qc.reject')} + + + )} + {q.status === 'APPROVED' && ( + + } + onClick={() => handlers.onReview(q, 'RETIRED')} + > + {t('question.qc.retire')} + + + )} - + {t('question.action.delete', 'Delete')} + - )} - {q.status === 'PENDING_APPROVAL' && ( - - - - - )} - {q.status === 'APPROVED' && ( - - - - )} - - handlers.onEdit(q)}> - - - handlers.onDelete(q)}> - - - - + + ); }, }; diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx index e5026c659..519c937f5 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -13,10 +13,12 @@ import { Select, NumberInput, Textarea, + Checkbox, + ActionIcon, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; -import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; +import { IconPlus, IconInfoCircle, IconTrash, IconGripVertical } from '@tabler/icons-react'; import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -28,11 +30,87 @@ import { useDeleteQuestionMutation, useSubmitQuestionMutation, useReviewQuestionMutation, + useSetQuestionOptionsMutation, } from '../../api/question-api'; -import type { Question, QuestionForm } from '../../types/question'; +import type { Question, QuestionForm, QuestionOptionInput } from '../../types/question'; +import { QuestionOptionsEditor } from '../../components/QuestionOptionsEditor'; import { questionColumns } from './columns'; import { questionActionsColumn } from './actions'; +type DraftOption = { textEn: string; textAm: string; isCorrect: boolean }; + +const BLANK_DRAFT_OPTIONS: DraftOption[] = [ + { textEn: '', textAm: '', isCorrect: false }, + { textEn: '', textAm: '', isCorrect: false }, +]; + +/** + * Options for a brand-new CHOICE question, entered inline in the same + * modal — no question id exists yet, so this is pure local state, only + * turned into a real setOptions() call once the question itself is + * created (see QuestionPage.handleSubmit). + */ +function InlineOptionsEditor({ + options, + onChange, +}: { + options: DraftOption[]; + onChange: (options: DraftOption[]) => void; +}) { + const { t } = useTranslation(); + + const update = (index: number, patch: Partial) => + onChange(options.map((o, i) => (i === index ? { ...o, ...patch } : o))); + + return ( + + {options.map((option, index) => ( + + + + update(index, { textEn: e.currentTarget.value })} + size="sm" + required + /> + update(index, { textAm: e.currentTarget.value })} + size="sm" + required + /> + + update(index, { isCorrect: !option.isCorrect })} + /> + onChange(options.filter((_, i) => i !== index))} + > + + + + ))} + + + ); +} + function QuestionForm({ editing, certOptions, @@ -52,6 +130,7 @@ function QuestionForm({ days: number; hours: number; minutes: number; + draftOptions: DraftOption[]; }, isEdit: boolean) => void; onCancel: () => void; }) { @@ -65,6 +144,7 @@ function QuestionForm({ const [days, setDays] = useState(editing?.time?.days ?? 0); const [hours, setHours] = useState(editing?.time?.hours ?? 0); const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0); + const [draftOptions, setDraftOptions] = useState(BLANK_DRAFT_OPTIONS); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -72,8 +152,23 @@ function QuestionForm({ notify.error('Please fill all required fields'); return; } + if (!editing && form === 'CHOICE') { + if (draftOptions.length < 2) { + notify.error(t('question.options.needAtLeastTwo')); + return; + } + if (!draftOptions.some((o) => o.isCorrect)) { + notify.error(t('question.options.needOneCorrect')); + return; + } + if (draftOptions.some((o) => !o.textEn.trim() || !o.textAm.trim())) { + notify.error(t('question.options.textRequired')); + return; + } + } onSubmit({ - certificationId, titleEn, titleAm, form, points, days, hours, minutes + certificationId, titleEn, titleAm, form, points, days, hours, minutes, + draftOptions: !editing && form === 'CHOICE' ? draftOptions : [], }, !!editing); }; @@ -92,6 +187,18 @@ function QuestionForm({ setHours(Number(v))} min={0} size="sm" /> setMinutes(Number(v))} min={0} size="sm" /> + {editing && form === 'CHOICE' && ( + <> + {t('question.options.title')} + + + )} + {!editing && form === 'CHOICE' && ( + <> + {t('question.options.title')} + + + )} @@ -112,6 +219,7 @@ export function QuestionPage() { const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation(); const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation(); const [deleteQ] = useDeleteQuestionMutation(); + const [setOptions, { isLoading: isSavingOptions }] = useSetQuestionOptionsMutation(); const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation(); const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation(); @@ -139,6 +247,7 @@ export function QuestionPage() { const handleSubmit = async (values: { certificationId: string; titleEn: string; titleAm: string; form: string; points: number; days: number; hours: number; minutes: number; + draftOptions: DraftOption[]; }, isEdit: boolean) => { const title = { en: values.titleEn, am: values.titleAm }; const time = { days: values.days, hours: values.hours, minutes: values.minutes }; @@ -147,7 +256,18 @@ export function QuestionPage() { await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap(); notify.success(t('question.updated')); } else { - await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap(); + const created = await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap(); + // The question needs an id to attach options to — this is the second + // half of one "create" action from the user's point of view, not a + // separate edit step, so it happens right here rather than waiting + // for them to reopen the question later. + if (values.form === 'CHOICE' && values.draftOptions.length) { + const options: QuestionOptionInput[] = values.draftOptions.map((o) => ({ + text: { en: o.textEn, am: o.textAm }, + isCorrect: o.isCorrect, + })); + await setOptions({ id: created.id, options }).unwrap(); + } notify.success(t('question.created')); } resetForm(); @@ -232,7 +352,7 @@ export function QuestionPage() { diff --git a/apps/backoffice/src/app/features/question/types/question.ts b/apps/backoffice/src/app/features/question/types/question.ts index a95bba2b8..b300bc881 100644 --- a/apps/backoffice/src/app/features/question/types/question.ts +++ b/apps/backoffice/src/app/features/question/types/question.ts @@ -16,6 +16,17 @@ export type QuestionStatus = | 'REJECTED' | 'RETIRED'; +/** + * A CHOICE option, as returned by the authoring/QC endpoints. Never carries + * a correctness flag — the API's own answer-key table is never joined into + * this response either, so there's nothing to accidentally serialize here. + */ +export interface QuestionOption { + id: string; + text: LocalePair; + order: number; +} + export interface Question { id: string; certificationId: string; @@ -32,6 +43,8 @@ export interface Question { submittedAt: string | null; createdAt: string; updatedAt: string; + /** Only populated when explicitly requested (`?i=options`). */ + options?: QuestionOption[]; } export interface ReviewQuestionPayload { @@ -64,3 +77,13 @@ export interface UpdateQuestionPayload { points?: number; isActive?: boolean; } + +export interface QuestionOptionInput { + text: LocalePair; + isCorrect: boolean; +} + +export interface SetQuestionOptionsPayload { + id: string; + options: QuestionOptionInput[]; +} diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx index d24ad573f..0921e9459 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx @@ -1,5 +1,5 @@ -import { Button, Group } from '@mantine/core'; -import { IconEye, IconTrash } from '@tabler/icons-react'; +import { ActionIcon, Menu } from '@mantine/core'; +import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -13,6 +13,7 @@ export function resultActionsColumn( onQc: (result: Result, action: QcAction) => void; onViewDetail: (result: Result) => void; onDelete: (result: Result) => void; + onPublish: (result: Result) => void; }, ): AdvancedColumn { return { @@ -21,49 +22,66 @@ export function resultActionsColumn( cell: ({ row }) => { const r = row.original; return ( - - {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( - <> - - + + + + + + + + } onClick={() => handlers.onViewDetail(r)}> + {t('result.action.viewEdit')} + + {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( + <> + + handlers.onQc(r, 'moderate')}> + {t('result.review.moderate')} + + + + handlers.onQc(r, 'approve')}> + {t('result.review.approve')} + + + + )} + {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( + + handlers.onQc(r, 'return')}> + {t('result.review.return')} + - - + )} + {r.reviewStatus === 'APPROVED' && ( + + } + onClick={() => handlers.onPublish(r)} + > + {t('result.review.publish')} + - - )} - {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( - - + )} + + + } + onClick={() => handlers.onDelete(r)} + > + {t('result.action.delete')} + - )} - - - - - + + ); }, }; diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index 583ea57d0..422555080 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -126,6 +126,8 @@ export function ResultPage() { const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); + const [publishTarget, setPublishTarget] = useState(null); + const [publishOpened, { open: openPublish, close: closePublish }] = useDisclosure(false); const [detailRemark, setDetailRemark] = useState({ en: '', am: '' }); const [detailBreakdowns, setDetailBreakdowns] = useState([]); const [detailSaving, setDetailSaving] = useState(false); @@ -262,6 +264,19 @@ export function ResultPage() { } }; + /** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */ + const handleConfirmPublish = async () => { + if (!publishTarget) return; + try { + const outcome = await publishResults(publishTarget.examId).unwrap(); + notify.success(t('result.review.publishedCount', outcome)); + closePublish(); + setPublishTarget(null); + } catch (error) { + notify.error(extractErrorMessage(error, t('result.review.error'))); + } + }; + const handleDetailClose = () => { closeDetail(); setDetailBreakdowns([]); @@ -288,6 +303,7 @@ export function ResultPage() { onQc: openQc, onViewDetail: viewDetail, onDelete: (r) => { setDeleteTarget(r); openDelete(); }, + onPublish: (r) => { setPublishTarget(r); openPublish(); }, }), ]; @@ -314,9 +330,11 @@ export function ResultPage() { {t('result.review.publish')} - + + + @@ -504,14 +522,22 @@ export function ResultPage() { - + + ) : ( @@ -574,6 +600,20 @@ export function ResultPage() { + + + {t('result.review.publishConfirmText', { + exam: publishTarget ? getExamTitle(publishTarget.examId) : '', + })} + + + + + + + {/* Choose exam, then record */} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 6fe440ddc..772092fac 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -255,6 +255,7 @@ export const am: Translations = { choice: "ምርጫ", offline: "ከመስመር ውጪ", online: "በመስመር", + onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።", sum: "ድምር", average: "አማካይ", percentage: "መቶኛ", @@ -262,6 +263,11 @@ export const am: Translations = { random: "በዘፈቀደ", cuttingPoint: "የማለፊያ ነጥብ", cuttingPointPlaceholder: "ለማለፍ ዝቅተኛ ነጥብ", + cuttingPointPercentagePlaceholder: "ለማለፍ ዝቅተኛ መቶኛ (0-100)", + cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።", + fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።", + fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።", + directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።", status: "ሁኔታ", statusPlaceholder: "የፈተና ሁኔታ", pending: "በመጠባበቅ ላይ", @@ -326,6 +332,10 @@ export const am: Translations = { retake: "ድጋሚ {{n}}", firstSitting: "የመጀመሪያ ሙከራ", remarkRequired: "ለመውጣት ወይም ለመታገድ ምክንያት ያስፈልጋል።", + regrade: "እንደገና ደረጃ ስጥ", + regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።", + regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።", + regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።", }, attendance: { REGISTERED: "አልተጠራም", @@ -370,6 +380,8 @@ export const am: Translations = { randomSelected: "{{count}} የጸደቁ ጥያቄዎች ተመርጠዋል", randomError: "ጥያቄዎችን መምረጥ አልተቻለም", notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።", + cannotReachCuttingPoint: + "ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።", }, country: { @@ -687,6 +699,8 @@ export const am: Translations = { returned: "ውጤት ወደ ፈታኙ ተመልሷል", publishedCount: "{{published}} ውጤቶች ወጥተዋል፤ {{skipped}} ተዘለዋል።", publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።", + publishConfirmText: + "ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?", lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።", originalScore: "የፈታኙ ጠቅላላ", derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)", @@ -778,6 +792,22 @@ export const am: Translations = { onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።", error: "ተግባሩ አልተሳካም", }, + options: { + title: "የመልስ አማራጮች", + hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።", + optionLabel: "አማራጭ {{number}}", + optionEn: "አማራጭ {{number}} (እንግሊዝኛ)", + optionAm: "አማራጭ {{number}} (አማርኛ)", + correct: "ትክክለኛ", + addOption: "አማራጭ ጨምር", + save: "አማራጮችን አስቀምጥ", + saved: "አማራጮች ተቀምጠዋል", + saveFirst: "መጀመሪያ ጥያቄውን አስቀምጥ፣ ከዚያ አማራጮችን ጨምር።", + replaceNotice: "ትክክለኛ መልሶች ከተቀመጡ በኋላ እዚህ አይታዩም — እንደገና ካስተካከልክ/ካስተካከልሽ ዳግም ምረጥ/ምረጪ።", + needAtLeastTwo: "ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልገዋል።", + needOneCorrect: "ቢያንስ አንድ አማራጭ እንደ ትክክለኛ ምረጥ/ምረጪ።", + textRequired: "እያንዳንዱ አማራጭ በሁለቱም ቋንቋዎች ጽሑፍ ያስፈልገዋል።", + }, }, configuration: { diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 30c53417b..f08b1642a 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -253,6 +253,7 @@ export const en = { choice: 'Choice', offline: 'Offline', online: 'Online', + onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.', sum: 'Sum', average: 'Average', percentage: 'Percentage', @@ -260,6 +261,11 @@ export const en = { random: 'Random', cuttingPoint: 'Cutting Point (Pass Mark)', cuttingPointPlaceholder: 'Minimum score to pass', + cuttingPointPercentagePlaceholder: 'Minimum % to pass (0-100)', + cuttingPointPercentageHint: 'Percentage evaluation — capped at 100.', + fillRequiredBasic: 'Please fill all required fields in Basic Info.', + fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.', + directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.', status: 'Status', statusPlaceholder: 'Exam status', pending: 'Pending', @@ -323,6 +329,10 @@ export const en = { retake: 'Retake {{n}}', firstSitting: 'First sitting', remarkRequired: 'A reason is required for a withdrawal or a disqualification.', + regrade: 'Regrade', + regraded: 'Result created from the graded attempt.', + regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.', + regradeError: 'Could not regrade this attempt.', }, attendance: { REGISTERED: 'Not called', @@ -368,6 +378,8 @@ export const en = { randomError: 'Could not draw questions', notEnoughApproved: 'Not enough approved questions in the bank for this subject.', + cannotReachCuttingPoint: + 'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.', }, country: { @@ -687,6 +699,8 @@ export const en = { returned: 'Result returned to the examiner', publishedCount: 'Published {{published}} result(s); {{skipped}} skipped.', publishNeedsExam: 'Filter by an exam first to publish its results.', + publishConfirmText: + 'This publishes every approved result for {{exam}} — not just this one — and notifies each candidate. Continue?', lockedAfterApproval: 'This result is approved and can no longer be edited. Return it to the examiner first.', originalScore: 'Examiner total', @@ -780,6 +794,23 @@ export const en = { 'Only approved items can be placed on an examination paper.', error: 'Operation failed', }, + options: { + title: 'Answer Options', + hint: 'Mark every correct option. Saving replaces the entire option set.', + optionLabel: 'Option {{number}}', + optionEn: 'Option {{number}} (English)', + optionAm: 'Option {{number}} (Amharic)', + correct: 'Correct', + addOption: 'Add option', + save: 'Save options', + saved: 'Options saved', + saveFirst: 'Save the question first, then add its options.', + replaceNotice: + 'Correct answers are never shown here once saved — re-mark them if you edit this set again.', + needAtLeastTwo: 'A question needs at least two options.', + needOneCorrect: 'Mark at least one option as correct.', + textRequired: 'Every option needs text in both languages.', + }, }, configuration: { diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx new file mode 100644 index 000000000..7f284a623 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamCompletion.tsx @@ -0,0 +1,50 @@ +import { Button, Card, Stack, Text, ThemeIcon, Title } from '@mantine/core'; +import { IconCircleCheck, IconClockPause } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import type { AttemptStatus } from '../types/exam-attempt'; + +/** + * No score, no pass/fail, nothing evaluation-shaped — grading hasn't run. + * This only confirms what actually happened: the candidate submitted, or + * the deadline closed the attempt out first. + */ +export function ExamCompletion({ + status, + submittedAt, +}: { + status: AttemptStatus; + submittedAt: string | null; +}) { + const navigate = useNavigate(); + const expired = status === 'EXPIRED'; + + return ( + + + + + {expired ? : } + + + {expired ? 'Time expired' : 'Exam submitted'} + + + {expired + ? 'The scheduled time ran out. Your saved answers were recorded as your final submission.' + : 'Your answers have been recorded.'} + {' '}Your result will appear on the Examinations page once marking, moderation and + approval are complete — it is not available yet. + + {submittedAt && ( + + {expired ? 'Closed' : 'Submitted'} at {new Date(submittedAt).toLocaleString()} + + )} + + + + + ); +} diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx new file mode 100644 index 000000000..e0bfa2c87 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx @@ -0,0 +1,94 @@ +import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/core'; +import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react'; +import type { Bilingual } from '@ema-platform/api'; +import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt'; + +function formatDuration(time: EstimatedTime | null | undefined): string { + if (!time) return 'Not configured'; + const parts = [ + time.days ? `${time.days}d` : null, + time.hours ? `${time.hours}h` : null, + time.minutes ? `${time.minutes}m` : null, + ].filter(Boolean); + return parts.length ? parts.join(' ') : '0m'; +} + +export function ExamInstructions({ + registration, + localized, + showDate, + starting, + onStart, +}: { + registration: RegistrationWithExam; + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + starting: boolean; + onStart: () => void; +}) { + const exam = registration.exam; + const canStart = exam?.status === 'ACTIVE'; + + return ( + + {localized(exam?.title) || 'Examination'} + {localized(exam?.certification?.name)} + + + + + Admission number + {registration.admissionNumber} + + + Session date + {showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''} + + + Duration + }> + {formatDuration(exam?.givenTime)} + + + + Attempt + + {registration.kind === 'RETAKE' + ? `Retake · sitting ${registration.attemptNumber}` + : 'First sitting'} + + + + + + {exam?.direction && localized(exam.direction) && ( + } color="blue" variant="light" title="Instructions"> + {localized(exam.direction)} + + )} + + } color="yellow" variant="light"> + Once started, the timer cannot be paused. Answers are saved automatically as you go, but + the exam ends the moment the deadline passes, whether or not you have submitted. + + + {!canStart && ( + + This session is not currently open for candidates to begin. + + )} + + + + + + ); +} diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx new file mode 100644 index 000000000..24570fe45 --- /dev/null +++ b/apps/portal/src/app/features/exam-attempt/components/ExamQuestionDisplay.tsx @@ -0,0 +1,118 @@ +import { Badge, Button, Group, Paper, Radio, Stack, Text, Textarea } from '@mantine/core'; +import { IconAlertCircle, IconCheck, IconRefresh } from '@tabler/icons-react'; +import type { Bilingual } from '@ema-platform/api'; +import type { CandidateQuestion, SaveState } from '../types/exam-attempt'; + +function SaveIndicator({ state, onRetry }: { state: SaveState; onRetry: () => void }) { + if (state === 'saving') { + return Saving…; + } + if (state === 'saved') { + return ( + + + Saved + + ); + } + if (state === 'error') { + return ( + + + Not saved + + + ); + } + return null; +} + +/** + * Renders one question — never the answer key, because the API response + * this reads from (`CandidateQuestion`/`CandidateOption`) has no such field + * to render even by mistake. + */ +export function ExamQuestionDisplay({ + question, + index, + total, + localized, + selectedOptionId, + answerText, + saveState, + disabled, + onSelectOption, + onChangeText, + onRetry, +}: { + question: CandidateQuestion; + index: number; + total: number; + localized: (value: Bilingual | undefined) => string; + selectedOptionId: string | null | undefined; + answerText: string | null | undefined; + saveState: SaveState; + disabled: boolean; + onSelectOption: (optionId: string) => void; + onChangeText: (text: string) => void; + onRetry: () => void; +}) { + return ( + + + + Question {index + 1} of {total} · {question.points} pts + + + + + + {localized(question.title)} + + + {question.form === 'CHOICE' ? ( + + + {question.options + .slice() + .sort((a, b) => a.order - b.order) + .map((option) => ( + + + + {localized(option.text)} + + + ))} + + + ) : ( +