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..2daab604a --- /dev/null +++ b/apps/backoffice/src/app/features/question/components/QuestionOptionsEditor.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { ActionIcon, Alert, Button, Checkbox, Group, Loader, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { IconGripVertical, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'; +import { BilingualInput, 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 updateText = (index: number, text: BilingualValue) => { + setDraft((prev) => prev.map((o, i) => (i === index ? { ...o, text } : 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) => ( + + + updateText(index, v)} + size="sm" + style={{ flex: 1 }} + /> + toggleCorrect(index)} + mb={4} + /> + removeOption(index)} + > + + + + ))} + + + + + {t('question.options.replaceNotice')} + + ); +} 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..1ee40bc5d 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -30,6 +30,7 @@ import { useReviewQuestionMutation, } from '../../api/question-api'; import type { Question, QuestionForm } from '../../types/question'; +import { QuestionOptionsEditor } from '../../components/QuestionOptionsEditor'; import { questionColumns } from './columns'; import { questionActionsColumn } from './actions'; @@ -92,6 +93,15 @@ 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.saveFirst')} + )} 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/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 92400f9ae..63d38a013 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -738,6 +738,20 @@ export const am: Translations = { onlyApprovedUsable: "የጸደቁ ጥያቄዎች ብቻ በፈተና ወረቀት ላይ ሊቀመጡ ይችላሉ።", error: "ተግባሩ አልተሳካም", }, + options: { + title: "የመልስ አማራጮች", + hint: "ትክክለኛውን አማራጭ ምረጥ/ምረጪ። ማስቀመጥ መላውን የአማራጭ ስብስብ ይተካል።", + optionLabel: "አማራጭ {{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 338a88e68..49a5a8b12 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -739,6 +739,21 @@ 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}}', + 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: {