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; } @@ -233,7 +255,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(""); @@ -248,7 +286,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 ? `` : ""} @@ -259,7 +310,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 cd5d7c192..e0adfa5ab 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -76,21 +76,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( @@ -120,7 +122,7 @@ function ExamForm({ return (
- + }> {t("exam.form.basicInfo")} @@ -251,6 +253,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 23fbe50c4..be7585ac5 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -1,12 +1,42 @@ -import { useState } from 'react'; -import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useTranslation } from 'react-i18next'; -import {IconPlus} from '@tabler/icons-react'; -import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui'; -import { extractErrorMessage } from '@ema-platform/api'; -import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; -import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; +import { useState } from "react"; +import { + Stack, + Title, + Group, + Button, + Badge, + Modal, + Text, + TextInput, + Card, + Alert, + Select, + NumberInput, + Textarea, + Checkbox, + ActionIcon, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { useTranslation } from "react-i18next"; +import { + IconPlus, + IconInfoCircle, + IconTrash, + IconGripVertical, +} from "@tabler/icons-react"; +import { + AdvancedColumn, + AdvancedTable, + ErrorState, + ModalFooter, + notify, + PageHeader, + useErrorHandler, + useServerTable, +} from "@ema-platform/ui"; +import { extractErrorMessage } from "@ema-platform/api"; +import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; +import { useGetCertificationsQuery } from "../../../certification/api/certification-api"; import { useGetQuestionsQuery, useCreateQuestionMutation, @@ -14,10 +44,92 @@ import { useDeleteQuestionMutation, useSubmitQuestionMutation, useReviewQuestionMutation, -} from '../../api/question-api'; -import type { Question, QuestionForm } from '../../types/question'; -import { questionColumns } from './columns'; -import { questionActionsColumn } from './actions'; + useSetQuestionOptionsMutation, +} from "../../api/question-api"; +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, @@ -29,58 +141,181 @@ function QuestionForm({ editing: Question | null; certOptions: { value: string; label: string }[]; isSubmitting: boolean; - onSubmit: (values: { - certificationId: string; - titleEn: string; - titleAm: string; - form: string; - points: number; - days: number; - hours: number; - minutes: number; - }, isEdit: boolean) => void; + onSubmit: ( + values: { + certificationId: string; + titleEn: string; + titleAm: string; + form: string; + points: number; + days: number; + hours: number; + minutes: number; + draftOptions: DraftOption[]; + }, + isEdit: boolean, + ) => void; onCancel: () => void; }) { const { t } = useTranslation(); - const [certificationId, setCertificationId] = useState(editing?.certificationId ?? null); - const [titleEn, setTitleEn] = useState(editing?.title?.en ?? ''); - const [titleAm, setTitleAm] = useState(editing?.title?.am ?? ''); + const [certificationId, setCertificationId] = useState( + editing?.certificationId ?? null, + ); + const [titleEn, setTitleEn] = useState(editing?.title?.en ?? ""); + const [titleAm, setTitleAm] = useState(editing?.title?.am ?? ""); const [form, setForm] = useState(editing?.form ?? null); const [points, setPoints] = useState(editing?.points ?? 0); 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(); if (!certificationId || !titleEn || !titleAm || !form) { - notify.error('Please fill all required fields'); + notify.error("Please fill all required fields"); return; } - onSubmit({ - certificationId, titleEn, titleAm, form, points, days, hours, minutes - }, !!editing); + 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, + draftOptions: !editing && form === "CHOICE" ? draftOptions : [], + }, + !!editing, + ); }; return ( - + - - setPoints(Number(v))} min={0} size="sm" required /> - {t('question.form.timeAllowed')} + + setPoints(Number(v))} + min={0} + size="sm" + required + /> + + {t("question.form.timeAllowed")} + - setDays(Number(v))} min={0} size="sm" /> - setHours(Number(v))} min={0} size="sm" /> - setMinutes(Number(v))} min={0} size="sm" /> + setDays(Number(v))} + min={0} + size="sm" + /> + 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")} + + + + )} - - + + @@ -90,7 +325,7 @@ function QuestionForm({ export function QuestionPage() { const { t, i18n } = useTranslation(); - const locale = i18n.language as 'en' | 'am'; + const locale = i18n.language as "en" | "am"; const { handleError } = useErrorHandler(); const { data: certRes } = useGetCertificationsQuery(); const { data, isFetching, isError, refetch } = useGetQuestionsQuery(); @@ -98,7 +333,10 @@ export function QuestionPage() { const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation(); const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation(); const [deleteQ] = useDeleteQuestionMutation(); - const [submitQ, { isLoading: isSubmittingReview }] = useSubmitQuestionMutation(); + const [setOptions, { isLoading: isSavingOptions }] = + useSetQuestionOptionsMutation(); + const [submitQ, { isLoading: isSubmittingReview }] = + useSubmitQuestionMutation(); const [reviewQ, { isLoading: isReviewing }] = useReviewQuestionMutation(); const certifications = certRes?.items ?? []; @@ -107,60 +345,115 @@ export function QuestionPage() { const [editing, setEditing] = useState(null); const [showForm, setShowForm] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); + const [deleteOpened, { open: openDelete, close: closeDelete }] = + useDisclosure(false); const [certFilter, setCertFilter] = useState(null); const [reviewTarget, setReviewTarget] = useState(null); - const [reviewOutcome, setReviewOutcome] = useState<'APPROVED' | 'REJECTED' | 'RETIRED'>('APPROVED'); - const [reviewRemark, setReviewRemark] = useState(''); + const [reviewOutcome, setReviewOutcome] = useState< + "APPROVED" | "REJECTED" | "RETIRED" + >("APPROVED"); + const [reviewRemark, setReviewRemark] = useState(""); - const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] })); + const certOptions = certifications + .filter((c) => c.isActive) + .map((c) => ({ value: c.id, label: c.name[locale] })); - const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter); + const filtered = questions.filter( + (q) => !certFilter || q.certificationId === certFilter, + ); const page = paginate(filtered); - const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-'; + const getCertName = (id: string) => + certifications.find((c) => c.id === id)?.name?.[locale] ?? "-"; - const resetForm = () => { setEditing(null); setShowForm(false); }; + const resetForm = () => { + setEditing(null); + setShowForm(false); + }; - const handleSubmit = async (values: { - certificationId: string; titleEn: string; titleAm: string; - form: string; points: number; days: number; hours: number; minutes: number; - }, isEdit: boolean) => { + 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 }; + const time = { + days: values.days, + hours: values.hours, + minutes: values.minutes, + }; try { if (isEdit && editing) { - await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap(); - notify.success(t('question.updated')); + 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(); - notify.success(t('question.created')); + 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(); } catch { - notify.error(t('question.error')); + notify.error(t("question.error")); } }; const handleSubmitForApproval = async (question: Question) => { try { await submitQ(question.id).unwrap(); - notify.success(t('question.qc.submitted')); + notify.success(t("question.qc.submitted")); } catch (error) { - notify.error(extractErrorMessage(error, t('question.qc.error'))); + notify.error(extractErrorMessage(error, t("question.qc.error"))); } }; - const openReview = (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => { + const openReview = ( + question: Question, + outcome: "APPROVED" | "REJECTED" | "RETIRED", + ) => { setReviewTarget(question); setReviewOutcome(outcome); - setReviewRemark(''); + setReviewRemark(""); }; const handleReview = async () => { if (!reviewTarget) return; - if (reviewOutcome !== 'APPROVED' && !reviewRemark.trim()) { - notify.error(t('question.qc.remarkRequired')); + if (reviewOutcome !== "APPROVED" && !reviewRemark.trim()) { + notify.error(t("question.qc.remarkRequired")); return; } try { @@ -169,10 +462,10 @@ export function QuestionPage() { outcome: reviewOutcome, remark: reviewRemark.trim() || undefined, }).unwrap(); - notify.success(t('question.qc.reviewed')); + notify.success(t("question.qc.reviewed")); setReviewTarget(null); } catch (error) { - notify.error(extractErrorMessage(error, t('question.qc.error'))); + notify.error(extractErrorMessage(error, t("question.qc.error"))); } }; @@ -180,7 +473,7 @@ export function QuestionPage() { if (!deleteTarget) return; try { await deleteQ(deleteTarget.id).unwrap(); - notify.success(t('question.deleted')); + notify.success(t("question.deleted")); closeDelete(); setDeleteTarget(null); } catch (e) { @@ -188,7 +481,8 @@ export function QuestionPage() { } }; - if (isError) return ; + if (isError) + return ; const columns: AdvancedColumn[] = [ ...questionColumns(t, { locale, getCertName }), @@ -196,21 +490,35 @@ export function QuestionPage() { isSubmittingReview, onSubmitForApproval: handleSubmitForApproval, onReview: openReview, - onEdit: (q) => { setEditing(q); setShowForm(true); }, - onDelete: (q) => { setDeleteTarget(q); openDelete(); }, + onEdit: (q) => { + setEditing(q); + setShowForm(true); + }, + onDelete: (q) => { + setDeleteTarget(q); + openDelete(); + }, }), ]; return ( -
) @@ -221,7 +529,7 @@ export function QuestionPage() { @@ -230,66 +538,98 @@ export function QuestionPage() { { setCertFilter(v ?? null); setPageIndex(0); }} + onChange={(v) => { + setCertFilter(v ?? null); + setPageIndex(0); + }} size="sm" style={{ width: 280 }} clearable /> } itemCount={page.itemCount} - pageIndex={page.pageIndex} - onPageChange={setPageIndex} - pageSize={pageSize} - onPageSizeChange={setPageSize} - refresh={refetch} - isLoading={isFetching} - emptyText={t('question.noQuestions')} - /> + pageIndex={page.pageIndex} + onPageChange={setPageIndex} + pageSize={pageSize} + onPageSizeChange={setPageSize} + refresh={refetch} + isLoading={isFetching} + emptyText={t("question.noQuestions")} + /> setReviewTarget(null)} - title={t('question.qc.reviewTitle')} + title={t("question.qc.reviewTitle")} size="md" radius="lg" > - {reviewTarget?.title?.[locale]} - {t('question.qc.onlyApprovedUsable')} - + + {reviewTarget?.title?.[locale]} + + + {t("question.qc.onlyApprovedUsable")} + + {t(`question.qc.${reviewOutcome}`)}