diff --git a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx index 3577cb379..fdb61a7ce 100644 --- a/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx +++ b/apps/backoffice/src/app/features/biometric-enrollment/pages/BiometricEnrollmentPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Alert, Badge, @@ -17,6 +17,9 @@ import { import { IconAlertTriangle, IconFingerprint, IconScan, IconSearch, IconX } from '@tabler/icons-react'; import { useDebouncedValue } from '@mantine/hooks'; import { + discoverMantraDevice, + captureFingerprint, + MantraCaptureFailedError, extractErrorMessage, useEnrollBiometricMutation, useGenerateBsidMutation, @@ -25,6 +28,8 @@ import { useListSeafarerRegistrationsQuery, useRevokeBiometricEnrollmentMutation, type BiometricModality, + type BiometricPosition, + type MantraDeviceInfo, type SeafarerRegistration, } from '@ema-platform/api'; import { notify, PageHeader, StatusBadge } from '@ema-platform/ui'; @@ -35,16 +40,30 @@ const MODALITIES: { value: BiometricModality; label: string }[] = [ { value: 'FACE', label: 'Face' }, ]; +const FINGER_POSITIONS: { value: BiometricPosition; label: string }[] = [ + { value: 'RIGHT_THUMB', label: 'Right thumb' }, + { value: 'RIGHT_INDEX', label: 'Right index' }, + { value: 'RIGHT_MIDDLE', label: 'Right middle' }, + { value: 'RIGHT_RING', label: 'Right ring' }, + { value: 'RIGHT_LITTLE', label: 'Right little' }, + { value: 'LEFT_THUMB', label: 'Left thumb' }, + { value: 'LEFT_INDEX', label: 'Left index' }, + { value: 'LEFT_MIDDLE', label: 'Left middle' }, + { value: 'LEFT_RING', label: 'Left ring' }, + { value: 'LEFT_LITTLE', label: 'Left little' }, +]; + function applicantName(r: Pick): string { return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—'; } /** - * No scanner is wired yet (US-BIO placeholder): "Simulate Scan" stands in for - * the real vendor SDK capture, producing a random template so the rest of the - * pipeline — encrypt, store, print — is exercisable end to end. Swap the - * simulated bytes for the SDK's real template once a vendor is chosen; the - * API call shape (base64 template + format tag) does not change. + * Fallback only (US-BIO placeholder): when `discoverMantraDevice()` finds no + * RD Service on this machine, "Simulate Scan" stands in for a real capture so + * the rest of the pipeline — encrypt, store, print — stays exercisable. Once + * a Mantra scanner answers discovery, `handleCaptureFromDevice` is used + * instead — see `mantra-capture-agent.ts`. Same API call shape either way + * (base64 template + format tag). */ function fakeTemplate(): string { const bytes = crypto.getRandomValues(new Uint8Array(64)); @@ -112,23 +131,72 @@ export function BiometricEnrollmentPage() { const profileId = selected?.profileId ?? ''; const { data: enrollments, isLoading } = useGetBiometricEnrollmentsQuery(profileId, { skip: !profileId }); - // No vendor SDK integrated yet — "Simulate Scan" fakes a capture so the - // rest of the flow is exercisable. Reports false in production unless + // "Simulate Scan" fakes a capture so the rest of the flow is exercisable + // where no scanner is present. Reports false in production unless // ALLOW_BIOMETRIC_SIMULATION=true, same shortcut the payment bypass uses. const { data: capabilities } = useGetBiometricSimulateCapabilitiesQuery(); const simulateEnabled = capabilities?.simulateEnabled ?? false; + + // Probed once per page load: is Mantra's RD Service running on this + // counter PC? `null` means "not checked yet / not found" — capture then + // falls back to Simulate Scan, same as before a device was ever expected. + const [device, setDevice] = useState(null); + const [probingDevice, setProbingDevice] = useState(true); + useEffect(() => { + let cancelled = false; + setProbingDevice(true); + discoverMantraDevice() + .then((found) => { if (!cancelled) setDevice(found); }) + .finally(() => { if (!cancelled) setProbingDevice(false); }); + return () => { cancelled = true; }; + }, []); + + const [position, setPosition] = useState('RIGHT_THUMB'); const [generateBsid, { isLoading: generatingBsid }] = useGenerateBsidMutation(); // Seeded from the seafarer registration list (which does not carry BSID // yet) and updated locally once generated — this screen's only source of // truth for it until the registry surfaces the profile's BSID directly. const [bsid, setBsid] = useState(null); const [enroll, { isLoading: enrolling }] = useEnrollBiometricMutation(); + const [capturing, setCapturing] = useState(false); const [revoke, { isLoading: revoking }] = useRevokeBiometricEnrollmentMutation(); const hasActive = useMemo( () => (m: BiometricModality) => (enrollments ?? []).some((e) => e.modality === m), [enrollments], ); + const positionEnrolled = useMemo( + () => (p: BiometricPosition) => (enrollments ?? []).some((e) => e.modality === 'FINGERPRINT' && e.position === p), + [enrollments], + ); + + /** Real scanner path: capture from the device that answered discovery, then enroll exactly as Simulate Scan does. */ + async function handleCaptureFromDevice() { + if (!profileId || !device) return; + setCapturing(true); + try { + const capture = await captureFingerprint(device, position); + await enroll({ + profileId, + modality: 'FINGERPRINT', + position, + template: capture.template, + templateFormat: capture.templateFormat, + qualityScore: capture.qualityScore, + deviceId: capture.deviceId, + consentAt: new Date().toISOString(), + }).unwrap(); + notify.success(`Fingerprint (${FINGER_POSITIONS.find((f) => f.value === position)?.label}) enrolled.`); + } catch (err) { + notify.error( + err instanceof MantraCaptureFailedError + ? err.message + : extractErrorMessage(err, 'Enrollment failed.'), + ); + } finally { + setCapturing(false); + } + } async function handleEnroll() { if (!profileId) return; @@ -136,6 +204,7 @@ export function BiometricEnrollmentPage() { await enroll({ profileId, modality, + position: modality === 'FINGERPRINT' ? position : undefined, template: fakeTemplate(), templateFormat: 'SIMULATED', deviceId: deviceId || undefined, @@ -206,13 +275,42 @@ export function BiometricEnrollmentPage() { Capture - {simulateEnabled ? ( + {probingDevice ? ( + Looking for a scanner… + ) : device ? ( + <> + } mb="sm" variant="light"> + Mantra scanner detected ({device.deviceId}). Place the finger below and capture. + + + setModality((v as BiometricModality) ?? 'FINGERPRINT')} w={160} /> + {modality === 'FINGERPRINT' && ( + setForm((v as QuestionForm) ?? null)} + size="sm" + required + /> + setPoints(Number(v))} + min={1} + size="sm" + required + /> + + {form === 'CHOICE' && ( + + {t('exam.newQuestion.options')} + {options.map((option, index) => ( + + updateOption(index, { textEn: e.currentTarget.value })} + size="sm" + style={{ flex: 1 }} + required + /> + updateOption(index, { textAm: e.currentTarget.value })} + size="sm" + style={{ flex: 1 }} + /> + updateOption(index, { isCorrect: !option.isCorrect })} + mb={6} + /> + setOptions((current) => current.filter((_, i) => i !== index))} + > + + + + ))} + + + )} + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionImportModal.tsx b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionImportModal.tsx new file mode 100644 index 000000000..d6fe65697 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionImportModal.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Anchor, + Badge, + Button, + FileInput, + Group, + Modal, + ScrollArea, + Stack, + Table, + Text, +} from '@mantine/core'; +import { IconCircleCheck, IconDownload, IconFileSpreadsheet, IconInfoCircle } from '@tabler/icons-react'; +import { ModalFooter, notify } from '@ema-platform/ui'; +import { downloadAuthedFile, extractErrorMessage } from '@ema-platform/api'; +import { useImportExamQuestionsMutation } from '../../api/exam-api'; +import type { Exam, QuestionImportReport } from '../../types/exam'; +import { describeExamQuestionError, describeImportError } from './errors'; + +/** + * "Import from Excel": upload → validate (a dry run on the server, which + * reports every error at once) → preview → import. The real import runs the + * same validation again and is all-or-nothing, so the preview the officer + * confirmed is what lands on the paper, or nothing does. + */ +export function ExamQuestionImportModal({ + exam, + opened, + onClose, +}: { + exam: Exam; + opened: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [importQuestions, { isLoading }] = useImportExamQuestionsMutation(); + const [file, setFile] = useState(null); + const [report, setReport] = useState(null); + + const close = () => { + setFile(null); + setReport(null); + onClose(); + }; + + const run = async (dryRun: boolean) => { + if (!file) return; + try { + const outcome = await importQuestions({ examId: exam.id, file, dryRun }).unwrap(); + setReport(outcome); + if (!dryRun && outcome.imported > 0) { + notify.success(t('exam.import.imported', { count: outcome.imported })); + close(); + } + } catch (error) { + notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error')))); + } + }; + + const downloadTemplate = async () => { + try { + await downloadAuthedFile('/exams/questions/import-template', 'exam-questions-template.xlsx'); + } catch (error) { + notify.error(extractErrorMessage(error, t('exam.error'))); + } + }; + + const valid = report !== null && report.errors.length === 0 && report.rows.length > 0; + + return ( + + + + {t('exam.import.hint')}{' '} + + {t('exam.import.template')} + + + } + value={file} + onChange={(next) => { + setFile(next); + setReport(null); + }} + size="sm" + /> + + {report && report.errors.length > 0 && ( + } title={t('exam.import.errors', { count: report.errors.length })}> + + + + + {t('exam.import.row')} + {t('exam.import.column')} + {t('exam.import.problem')} + + + + {report.errors.map((error, index) => ( + + {error.row || '—'} + {error.column ?? '—'} + {describeImportError(t, error.message)} + + ))} + +
+
+ {t('exam.import.nothingImported')} +
+ )} + + {report && report.rows.length > 0 && ( + + + {t('exam.import.preview', { count: report.rows.length })} + {valid && ( + }> + {t('exam.import.valid')} + + )} + + + + + + {t('exam.import.row')} + {t('exam.import.question')} + {t('exam.import.type')} + {t('exam.import.points')} + {t('exam.import.options')} + + + + {report.rows.map((row) => ( + + {row.row} + {row.titleEn} + {t(`exam.formType.${row.form}`)} + {row.points} + + {row.options.length + ? row.options.map((o) => (o.correct ? `${o.letter}✓` : o.letter)).join(' ') + : '—'} + + + ))} + +
+
+
+ )} + + + + + + +
+
+ ); +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/QuestionBankPickerModal.tsx b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/QuestionBankPickerModal.tsx new file mode 100644 index 000000000..38277cafc --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/QuestionBankPickerModal.tsx @@ -0,0 +1,138 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Modal, + ScrollArea, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { ModalFooter, notify } from '@ema-platform/ui'; +import { extractErrorMessage } from '@ema-platform/api'; +import { useGetQuestionsQuery } from '../../../question/api/question-api'; +import { useAddExamQuestionsMutation } from '../../api/exam-api'; +import type { Exam } from '../../types/exam'; +import { describeExamQuestionError } from './errors'; + +/** + * "Create question from question bank": pick approved items for this exam's + * subject and add them to the paper as it stands. Items already on the paper + * are not offered — the bank is reusable, the paper holds each item once. + */ +export function QuestionBankPickerModal({ + exam, + opened, + onClose, +}: { + exam: Exam; + opened: boolean; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const locale = i18n.language as 'en' | 'am'; + const { data: qRes, isFetching } = useGetQuestionsQuery(undefined, { skip: !opened }); + const [addQuestions, { isLoading }] = useAddExamQuestionsMutation(); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState>(new Set()); + + const onPaper = useMemo( + () => new Set((exam.questions ?? []).map((q) => q.id)), + [exam.questions], + ); + + // Only approved bank items of this subject can go on a paper (US-EXAM-003), + // and the form has to fit the session unless it is a mixed (BOTH) paper. + const candidates = useMemo( + () => + (qRes?.items ?? []).filter( + (q) => + q.certificationId === exam.certificationId && + q.status === 'APPROVED' && + q.isActive && + (exam.form === 'BOTH' || q.form === exam.form) && + !onPaper.has(q.id), + ), + [qRes, exam.certificationId, exam.form, onPaper], + ); + + const visible = search + ? candidates.filter((q) => + `${q.title.en ?? ''} ${q.title.am ?? ''}`.toLowerCase().includes(search.toLowerCase()), + ) + : candidates; + + const toggle = (id: string) => + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const close = () => { + setSelected(new Set()); + setSearch(''); + onClose(); + }; + + const add = async () => { + try { + await addQuestions({ examId: exam.id, questionIds: [...selected] }).unwrap(); + notify.success(t('exam.bank.added', { count: selected.size })); + close(); + } catch (error) { + notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error')))); + } + }; + + return ( + + + {t('exam.bank.hint')} + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + size="sm" + /> + {!isFetching && candidates.length === 0 ? ( + }>{t('exam.bank.empty')} + ) : ( + + + {visible.map((q) => ( + toggle(q.id)} + label={ + + {q.title[locale] || q.title.en} + + {t(`exam.formType.${q.form}`)} + + {q.points} pts + + } + /> + ))} + + + )} + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/errors.ts b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/errors.ts new file mode 100644 index 000000000..e3396aa5a --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/errors.ts @@ -0,0 +1,60 @@ +import type { TFunction } from 'i18next'; + +/** + * Error keys the question-management endpoints return, made readable. Keys + * carry detail after a colon (`paper_cannot_reach_cutting_point:20/50`), so + * the prefix is matched and the detail passed to the message. + */ +export function describeExamQuestionError(t: TFunction, key: string): string { + const [code, detail = ''] = key.split(':'); + switch (code) { + case 'paper_locked_after_registration': + return t('exam.paperLocked'); + case 'paper_cannot_reach_cutting_point': { + const [max, cuttingPoint] = detail.split('/'); + return t('exam.cannotReachCuttingPoint', { max, cuttingPoint }); + } + case 'question_not_approved': + return t('question.qc.onlyApprovedUsable'); + case 'question_subject_mismatch': + return t('exam.questionErrors.subjectMismatch'); + case 'question_not_found': + return t('exam.questionErrors.notFound'); + case 'options_required': + return t('exam.newQuestion.needTwo'); + case 'at_least_one_correct_option_required': + return t('exam.newQuestion.needCorrect'); + case 'invalid_points': + return t('exam.newQuestion.fillRequired'); + case 'excel_file_required': + case 'file_required': + return t('exam.import.errorKeys.invalid_excel_file'); + default: + return key; + } +} + +/** Row-level import problems, as the validator names them. */ +export function describeImportError(t: TFunction, message: string): string { + const [code, detail = ''] = message.split(':'); + const known = [ + 'question_text_required', + 'invalid_question_type', + 'invalid_points', + 'options_required', + 'correct_answer_required', + 'correct_answer_invalid', + 'duplicate_in_file', + 'duplicate_in_bank', + 'missing_columns', + 'too_many_rows', + 'no_questions_in_file', + 'invalid_excel_file', + ]; + if (code === 'paper_cannot_reach_cutting_point') { + const [max, cuttingPoint] = detail.split('/'); + return t('exam.cannotReachCuttingPoint', { max, cuttingPoint }); + } + if (known.includes(code)) return t(`exam.import.errorKeys.${code}`, { detail }); + return message; +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/index.ts b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/index.ts new file mode 100644 index 000000000..7eb3413fe --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/index.ts @@ -0,0 +1,4 @@ +export { QuestionBankPickerModal } from './QuestionBankPickerModal'; +export { ExamQuestionCreateModal } from './ExamQuestionCreateModal'; +export { ExamQuestionImportModal } from './ExamQuestionImportModal'; +export { describeExamQuestionError } from './errors'; diff --git a/apps/backoffice/src/app/features/exam/components/ExamWaitMetricsPanel.tsx b/apps/backoffice/src/app/features/exam/components/ExamWaitMetricsPanel.tsx new file mode 100644 index 000000000..694eeda30 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamWaitMetricsPanel.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; +import { Group, Paper, SimpleGrid, Text, Title, Badge } from '@mantine/core'; +import { IconHourglass } from '@tabler/icons-react'; +import { useGetExamWaitMetricsQuery } from '../api/exam-api'; +import type { WaitStat } from '../types/exam'; + +function minutes(value: number | null, t: (key: string, options?: Record) => string): string { + if (value === null) return '—'; + const abs = Math.abs(value); + const label = + abs >= 1440 + ? t('exam.metrics.days', { value: Math.round((abs / 1440) * 10) / 10 }) + : abs >= 60 + ? t('exam.metrics.hours', { value: Math.round((abs / 60) * 10) / 10 }) + : t('exam.metrics.minutes', { value: Math.round(abs * 10) / 10 }); + return value < 0 ? `−${label}` : label; +} + +function StatCard({ label, stat, t }: { label: string; stat: WaitStat; t: (key: string, options?: Record) => string }) { + return ( + + {label} + {minutes(stat.averageMinutes, t)} + {t('exam.metrics.average')} + + {t('exam.metrics.min')}: {minutes(stat.minMinutes, t)} + {t('exam.metrics.max')}: {minutes(stat.maxMinutes, t)} + {t('exam.metrics.count')}: {stat.count} + + + ); +} + +/** + * Exam wait metrics — how long candidates waited at each step up to the + * sitting, read off the registration, attendance and attempt timestamps + * the workflow already writes. Analytics only: nothing here can change a + * registration, an attendance ruling, a result or a certificate. + */ +export function ExamWaitMetricsPanel({ examId }: { examId: string }) { + const { t } = useTranslation(); + const { data, isError } = useGetExamWaitMetricsQuery(examId); + if (isError || !data) return null; + + return ( + + + + + {t('exam.metrics.section')} + + + {t('exam.metrics.candidates', { count: data.candidateCount })} + {t('exam.metrics.attended', { count: data.attendedCount })} + {t('exam.metrics.started', { count: data.startedCount })} + + + {t('exam.metrics.hint')} + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index ff8759458..c4d5eeb35 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -23,6 +23,7 @@ import { ThemeIcon, Box, Tooltip, + Menu, rem, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; @@ -40,6 +41,11 @@ import { IconUser, IconCheck, IconX, + IconDatabase, + IconFileSpreadsheet, + IconPencilPlus, + IconListCheck, + IconChevronDown, } from '@tabler/icons-react'; import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; @@ -57,6 +63,12 @@ import { QuestionAssigner } from '../components/QuestionAssigner'; import { RecordResultModal } from '../../result/components/RecordResultModal'; import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel'; import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel'; +import { ExamWaitMetricsPanel } from '../components/ExamWaitMetricsPanel'; +import { + ExamQuestionCreateModal, + ExamQuestionImportModal, + QuestionBankPickerModal, +} from '../components/ExamQuestionActions'; import { PageLoader } from '@ema-platform/ui'; import type { ExamStatus, QuestionBrief } from '../types/exam'; @@ -66,7 +78,6 @@ const STATUS_TONE: Record = { COMPLETED: 'success', CANCELLED: 'danger', POSTPONED: 'pending', - PUBLISHED: 'success', }; const FORM_LABEL: Record = { @@ -109,6 +120,11 @@ export function ExamDetailPage() { useDisclosure(false); const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false); + // The three contextual ways to populate this exam's paper without leaving it. + const [bankOpened, { open: openBank, close: closeBank }] = useDisclosure(false); + const [importOpened, { open: openImport, close: closeImport }] = useDisclosure(false); + const [newQuestionOpened, { open: openNewQuestion, close: closeNewQuestion }] = + useDisclosure(false); const [draftQuestions, setDraftQuestions] = useState([]); const [randomCount, setRandomCount] = useState(5); const [updateExam] = useUpdateExamMutation(); @@ -415,6 +431,14 @@ export function ExamDetailPage() { /> + - + {/* One place to add questions from this exam's own page: + the bank, an Excel sheet, or a brand-new item — every + path lands the question on this paper. */} + + + + + + } onClick={openBank}> + {t("exam.questionsMenu.fromBank")} + + } onClick={openImport}> + {t("exam.questionsMenu.importExcel")} + + } onClick={openNewQuestion}> + {t("exam.questionsMenu.fromScratch")} + + + } onClick={openAssignModal}> + {t("exam.questionsMenu.managePaper")} + + + @@ -539,8 +585,13 @@ export function ExamDetailPage() { {/* Exam-day operations: who sat the paper, and what went wrong */} + {/* Analytics over the same records — read-only, never a step in the workflow */} + + + + {/* Question assignment modal */} = { COMPLETED: 'success', CANCELLED: 'danger', POSTPONED: 'pending', - PUBLISHED: 'success', }; export function examColumns( @@ -41,7 +40,18 @@ export function examColumns( }, { header: t("exam.columns.date"), - cell: ({ row }) => {row.original.date}, + cell: ({ row }) => { + const start = row.original.startTime?.slice(0, 5); + const end = row.original.endTime?.slice(0, 5); + return ( + + {row.original.date} + {start || end ? ( + {` · ${start ?? "00:00"} – ${end ?? "23:59"}`} + ) : null} + + ); + }, }, { header: t("exam.columns.type"), 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 4ae3fd48e..e2305f0bd 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -26,7 +26,7 @@ import { import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui"; import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { useGetCertificationsQuery } from "../../../certification/api/certification-api"; -import { useGetRanksQuery, useLocalized } from "@ema-platform/api"; +import { extractErrorMessage, useGetRanksQuery, useLocalized } from "@ema-platform/api"; import { useGetExamsQuery, useCreateExamMutation, @@ -78,6 +78,8 @@ function ExamForm({ const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? ""); const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? ""); const [date, setDate] = useState(editing?.date ?? ""); + const [startTime, setStartTime] = useState(editing?.startTime?.slice(0, 5) ?? ""); + const [endTime, setEndTime] = useState(editing?.endTime?.slice(0, 5) ?? ""); const [days, setDays] = useState(editing?.givenTime?.days ?? 0); const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0); const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0); @@ -113,6 +115,8 @@ function ExamForm({ directionEn, directionAm, date, + startTime, + endTime, venue, type, form, @@ -150,6 +154,8 @@ function ExamForm({ directionEn, directionAm, date, + startTime, + endTime, days, hours, minutes, @@ -239,6 +245,26 @@ function ExamForm({ size="sm" required /> + {/* The session window. The backend refuses to start an attempt + before startTime (exam_not_started) and after endTime, on its + own clock — this is only where the officer sets it. */} + + setStartTime(e.currentTarget.value)} + size="sm" + /> + setEndTime(e.currentTarget.value)} + size="sm" + /> + + {t("exam.form.windowHint")} + { + const key = extractErrorMessage(error, ""); + if (key === "exam_window_invalid") return notify.error(t("exam.form.windowInvalid")); + if (key.startsWith("exam_scoring_locked_by_results")) { + return notify.error( + t("exam.errors.scoringLocked", { count: Number(key.split(":")[1] ?? 0) }), + ); + } + return handleError(error); + }; + const handleSubmit = async (values: any, isEdit: boolean) => { const payload: any = { certificationId: values.certificationId, @@ -531,6 +575,8 @@ export function ExamPage() { ? { en: values.directionEn, am: values.directionAm } : undefined, date: values.date, + startTime: values.startTime || null, + endTime: values.endTime || null, givenTime: { days: values.days, hours: values.hours, @@ -556,7 +602,7 @@ export function ExamPage() { } resetForm(); } catch (e) { - handleError(e); + describeError(e); } }; @@ -702,7 +748,6 @@ export function ExamPage() { { value: "COMPLETED", label: t("exam.form.completed") }, { value: "CANCELLED", label: t("exam.form.cancelled") }, { value: "POSTPONED", label: t("exam.form.postponed") }, - { value: "PUBLISHED", label: t("exam.form.published") }, ]} value={pendingStatus} onChange={(value) => setPendingStatus(value as Exam["status"])} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts index 1f85f4060..b7e9eaddb 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.spec.ts @@ -16,6 +16,8 @@ const complete: ExamFormValues = { directionEn: '', directionAm: '', date: '2026-09-10', + startTime: '', + endTime: '', venue: 'Addis Ababa', type: 'WRITTEN', form: 'CHOICE', @@ -86,3 +88,33 @@ describe('exam form step validation', () => { ); }); }); + +/** + * The session window is optional on both ends, but when both are given the + * exam cannot close before it opens — the same rule the API enforces as + * exam_window_invalid, reported here on the step that owns the fields. + */ +describe('session window', () => { + it('accepts no window, a start alone, an end alone, and a well-ordered pair', () => { + expect(validateBasic(complete)).toBeNull(); + expect(validateBasic({ ...complete, startTime: '10:00' })).toBeNull(); + expect(validateBasic({ ...complete, endTime: '12:00' })).toBeNull(); + expect(validateBasic({ ...complete, startTime: '10:00', endTime: '12:00' })).toBeNull(); + }); + + it('refuses an end at or before the start', () => { + expect(validateBasic({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe( + 'exam.form.windowInvalid', + ); + expect(validateBasic({ ...complete, startTime: '10:00', endTime: '10:00' })).toBe( + 'exam.form.windowInvalid', + ); + }); + + it('lands the user back on Basic Info to fix it', () => { + expect(stepOfError('exam.form.windowInvalid')).toBe(0); + expect(validateAll({ ...complete, startTime: '12:00', endTime: '10:00' })).toBe( + 'exam.form.windowInvalid', + ); + }); +}); diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts index 43bda456e..dc130d240 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/validation.ts @@ -12,6 +12,9 @@ export interface ExamFormValues { directionEn: string; directionAm: string; date: string; + /** `HH:MM` or empty — the session window, optional on both ends. */ + startTime: string; + endTime: string; venue: string; type: string | null; form: string | null; @@ -32,6 +35,12 @@ export function validateBasic(v: ExamFormValues): string | null { if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) { return 'exam.form.directionBothLanguages'; } + // A session cannot close before it opens. Same rule the API applies + // (exam_window_invalid), caught here so it is reported on the step that + // owns the fields. + if (v.startTime && v.endTime && v.endTime <= v.startTime) { + return 'exam.form.windowInvalid'; + } return null; } diff --git a/apps/backoffice/src/app/features/exam/types/exam.ts b/apps/backoffice/src/app/features/exam/types/exam.ts index 52adbd6fa..da51d5b36 100644 --- a/apps/backoffice/src/app/features/exam/types/exam.ts +++ b/apps/backoffice/src/app/features/exam/types/exam.ts @@ -14,8 +14,13 @@ export type ExamType = "WRITTEN" | "ORAL"; export type ExamAdministrationMethod = "OFFLINE" | "ONLINE"; export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE"; export type ExamSelectionMethod = "MANUAL" | "RANDOM"; +/** + * The session's own lifecycle. No PUBLISHED: whether a candidate can see + * their mark is that candidate's Result (`reviewStatus`/`publishedAt`), never + * a property of the exam every other candidate shares. Mirrors EExamStatus. + */ export type ExamStatus = - "PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED"; + "PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED"; /** Only populated when the exam is fetched with `?i=questions,questions.options`. */ export interface QuestionOptionBrief { @@ -39,6 +44,10 @@ export interface Exam { title: LocalePair; direction: LocalePair | null; date: string; + /** `HH:MM[:SS]` on `date`, authority timezone; null means the session opens at the start of the day. */ + startTime: string | null; + /** `HH:MM[:SS]` on `date`; null means the end of the day. Only bounds *starting* an attempt. */ + endTime: string | null; givenTime: EstimatedTime | null; type: ExamType; form: ExamForm; @@ -63,6 +72,8 @@ export interface CreateExamPayload { title: LocalePair; direction?: LocalePair; date: string; + startTime?: string | null; + endTime?: string | null; givenTime: EstimatedTime; type: ExamType; form: ExamForm; @@ -79,6 +90,8 @@ export interface UpdateExamPayload { title?: LocalePair; direction?: LocalePair; date?: string; + startTime?: string | null; + endTime?: string | null; givenTime?: EstimatedTime; type?: ExamType; form?: ExamForm; @@ -128,8 +141,38 @@ export interface ExamRegistration { id: string; status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED"; } | null; + /** + * Where the sitting stands, derived server-side from this row, the attempt + * and the published mark — the same reading the COC queue/detail and the + * applicant's portal show. + */ + examState?: RegistrationExamState; + /** + * The mark already on file for this candidate, at any review stage. Null + * until someone (or the engine) has marked the paper — which is what + * decides whether the marking screen may still offer this candidate. + */ + result?: { + id: string; + status: "PASSED" | "FAILED"; + reviewStatus: "MARKED" | "MODERATED" | "APPROVED" | "PUBLISHED" | "RETURNED"; + autoGraded: boolean; + totalScore: number; + publishedAt: string | null; + } | null; } +/** Mirrors the server's ExamState (ExamStateService / resolveExamState). */ +export type RegistrationExamState = + | "NOT_REGISTERED" + | "REGISTERED" + | "ATTENDANCE_CONFIRMED" + | "NOT_SITTING" + | "IN_PROGRESS" + | "UNDER_EVALUATION" + | "PASSED" + | "FAILED"; + export type RegradeOutcome = { graded: true; resultId: string } | { graded: false; reason: string }; @@ -189,3 +232,83 @@ export interface ResolveIncidentPayload { outcome: "RESOLVED" | "DISMISSED"; resolution: string; } + +/** Bank items appended to a paper without replacing what is already on it. */ +export interface AddExamQuestionsPayload { + examId: string; + questionIds: string[]; +} + +export interface ExamQuestionOptionInput { + text: LocalePair; + isCorrect: boolean; +} + +/** A question authored straight onto one exam ("add new question from scratch"). */ +export interface CreateExamQuestionPayload { + examId: string; + title: LocalePair; + form: QuestionForm; + points: number; + time?: EstimatedTime; + options?: ExamQuestionOptionInput[]; +} + +export interface ImportedQuestionOption { + letter: string; + en: string; + am: string | null; + correct: boolean; +} + +export interface ImportedQuestionRow { + /** 1-based sheet row, as the officer sees it in Excel. */ + row: number; + titleEn: string; + titleAm: string | null; + form: QuestionForm; + points: number; + options: ImportedQuestionOption[]; +} + +export interface QuestionImportError { + /** 0 for a file-level problem (missing headers, empty sheet). */ + row: number; + column?: string; + message: string; +} + +/** What one Excel upload came to — the preview, or the errors that stopped it. */ +export interface QuestionImportReport { + rows: ImportedQuestionRow[]; + errors: QuestionImportError[]; + imported: number; + dryRun: boolean; +} + +export interface ImportExamQuestionsPayload { + examId: string; + file: File; + dryRun: boolean; +} + +/** Summary statistics over one wait interval, in minutes. */ +export interface WaitStat { + count: number; + averageMinutes: number | null; + minMinutes: number | null; + maxMinutes: number | null; +} + +/** Candidate waiting/scheduling delays for one session — read-only analytics. */ +export interface ExamWaitMetrics { + examId: string; + scheduledStart: string; + candidateCount: number; + attendedCount: number; + startedCount: number; + registrationToScheduled: WaitStat; + scheduledToAttendance: WaitStat; + attendanceToExamStart: WaitStat; + scheduledToExamStart: WaitStat; +} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx index 6137c9460..984d5b3b8 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx @@ -7,6 +7,7 @@ import { applicantOrCompanyName, localized, type ApplicationKind, + type ExamState, type LicenseApplication, type QueueFilter, } from "@ema-platform/api"; @@ -16,6 +17,18 @@ const KIND_COLOR: Record = { RENEWAL: "teal", REISSUE: "orange", }; + +/** Same palette as the COC detail's Examination panel. */ +const EXAM_STATE_COLORS: Record = { + NOT_REGISTERED: "gray", + REGISTERED: "cyan", + ATTENDANCE_CONFIRMED: "indigo", + NOT_SITTING: "orange", + IN_PROGRESS: "blue", + UNDER_EVALUATION: "yellow", + PASSED: "teal", + FAILED: "red", +}; import type { AdvancedColumn } from "@ema-platform/ui"; import { dateDisplayer } from "@ema-platform/shared"; import { computeSla } from "../../sla"; @@ -142,6 +155,23 @@ export function licenseQueueColumns( `queue.statusValues.${row.original.status}`, STATUS_LABELS[row.original.status], ); + // The exam leg is shown as the sitting actually stands — registered, + // present, sat, passed — derived server-side from the registration, + // the attempt and the published mark, the same reading the COC detail + // and the applicant's portal use. The application status alone + // lagged behind a published result, which is how the queue kept + // saying "exam scheduled" over a pass. + const examState = row.original.examState; + if (examState) { + const examLabel = t(`review.exam.state.${examState}`, examState); + return ( + + + {examLabel} + + + ); + } return ( diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index bcb410228..f253d8e16 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -34,6 +34,7 @@ import { IconPaperclip, IconPencil, IconQuestionMark, + IconRefresh, IconX, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; @@ -71,6 +72,7 @@ import { useScheduleInspectionMutation, useRescheduleInspectionMutation, useGetCertificateUrlForOfficerMutation, + useRegenerateCertificateMutation, uploadDocument, type ApplicationRemark, type RemarkTargetType, @@ -236,6 +238,8 @@ export function LicenseReviewPage() { const [scheduleIssuance] = useScheduleIssuanceMutation(); const [issueCertificate] = useIssueCertificateMutation(); const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation(); + const [regenerateCertificate, { isLoading: regeneratingCertificate }] = + useRegenerateCertificateMutation(); const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>( null, ); @@ -720,6 +724,37 @@ export function LicenseReviewPage() { } } + /** + * Re-renders the stored certificate from whatever design is published now. + * Unlike View/Download above, this overwrites the stored PDF even though + * one already exists — the fix for a certificate printed from a design + * that turned out to be wrong (e.g. a placeholder published for a rank), + * where View/Download would otherwise keep handing back the same bad file. + */ + async function handleRegenerateCertificate() { + if (!app.issuedLicenseId) return; + try { + await regenerateCertificate(app.issuedLicenseId).unwrap(); + notifications.show({ + color: "teal", + title: t("review.certificateRegenerated", "Certificate regenerated"), + message: t( + "review.certificateRegeneratedBody", + "Re-rendered from the currently published design.", + ), + }); + } catch (err) { + notifications.show({ + color: "red", + title: t( + "review.certificateRegenerateError", + "Could not regenerate the certificate", + ), + message: extractErrorMessage(err), + }); + } + } + /** Opens the booking modal seeded with the visit already on the books. */ function openReschedule() { if (!pendingInspection) return; @@ -1156,6 +1191,29 @@ export function LicenseReviewPage() { > {t("review.download", "Download")} + {/* Same authority as issuing in the first place — reprints + the stored PDF from whatever design is published now, + for when the one on file turns out to be wrong. */} + {can(["can:issue:license-certificate"]) && ( + + + + )} )} diff --git a/apps/backoffice/src/app/features/result/api/result-api.ts b/apps/backoffice/src/app/features/result/api/result-api.ts index d7f5b383d..11e7aaacb 100644 --- a/apps/backoffice/src/app/features/result/api/result-api.ts +++ b/apps/backoffice/src/app/features/result/api/result-api.ts @@ -61,6 +61,16 @@ const resultApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + /** + * Applicant-level publication: only this candidate's mark, notification + * and application are affected. The exam and every other candidate stay + * exactly as they were. + */ + publishResult: builder.mutation({ + query: (id) => ({ url: `/results/${id}/publish`, method: 'POST' }), + invalidatesTags: ['Api'], + }), + /** Every approved result for a session at once — a convenience over publishResult. */ publishExamResults: builder.mutation< { examId: string; published: number; skipped: number }, string @@ -98,6 +108,7 @@ export const { useModerateResultMutation, useApproveResultMutation, useReturnResultMutation, + usePublishResultMutation, usePublishExamResultsMutation, useGetPendingAppealsQuery, useDecideAppealMutation, diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx index 9503b1120..c82d8dbd3 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx @@ -7,9 +7,9 @@ export function recordResultColumns( t: TFunction, locale: 'en' | 'am', handlers: { - scores: Record; + scores: Record; questionRemarks: Record; - onScoreChange: (questionId: string, value: number) => void; + onScoreChange: (questionId: string, value: number | '') => void; onRemarkChange: (questionId: string, value: string) => void; /** The candidate's own answer + auto-score, when available (empty for * an OFFLINE candidate or one who hasn't sat an online attempt). */ @@ -54,8 +54,17 @@ export function recordResultColumns( return ( handlers.onScoreChange(row.original.id, Number(v))} + // Empty until the examiner types a mark: an untouched box is not + // a zero, and the form will not save while one is left empty. + value={handlers.scores[row.original.id] ?? ''} + onChange={(v) => + handlers.onScoreChange( + row.original.id, + v === '' || v === null || v === undefined ? '' : Number(v), + ) + } + placeholder={t('result.recordModal.scorePlaceholder')} + error={handlers.scores[row.original.id] === '' || handlers.scores[row.original.id] === undefined} min={0} max={row.original.points} size="xs" diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx index b073b1ca1..b97070cc0 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx @@ -45,7 +45,10 @@ export function RecordResultModal({ const locale = i18n.language as 'en' | 'am'; const [seafarerSearch, setSeafarerSearch] = useState(''); const [selectedSeafarerId, setSelectedSeafarerId] = useState(null); - const [scores, setScores] = useState>({}); + // A score is either a number the examiner typed or not yet entered. It is + // never defaulted to 0: an untouched box must not become a mark of zero, + // and a paper with a box left empty must not be saved at all. + const [scores, setScores] = useState>({}); const [questionRemarks, setQuestionRemarks] = useState>({}); const [remark, setRemark] = useState(''); @@ -78,7 +81,7 @@ export function RecordResultModal({ // only ever fills in blanks, never stomps a manual edit already made. useEffect(() => { if (!gradingSheet) return; - const autoScores: Record = {}; + const autoScores: Record = {}; for (const q of gradingSheet.questions) { if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore; } @@ -88,10 +91,17 @@ export function RecordResultModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [gradingSheet]); - const seafarerOptions = (registrations ?? []) - .filter((registration) => - ['PRESENT', 'LATE'].includes(registration.attendanceStatus), - ) + // Only candidates who sat the paper *and have no mark yet*. A candidate the + // exam engine has already graded — or an examiner has already marked — is + // not offered: the API refuses a second result (result_already_recorded), + // and an engine-produced mark is locked in any case. Nobody is asked to + // hand-record a result the system already holds. + const sat = (registrations ?? []).filter((registration) => + ['PRESENT', 'LATE'].includes(registration.attendanceStatus), + ); + const alreadyMarked = sat.filter((registration) => registration.result).length; + const seafarerOptions = sat + .filter((registration) => !registration.result) .map((registration) => ({ value: registration.profileId, label: `${registration.admissionNumber} — ${[ @@ -107,7 +117,8 @@ export function RecordResultModal({ ? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase())) : seafarerOptions; - const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0); + const unscored = questions.filter((q) => scores[q.id] === '' || scores[q.id] === undefined); + const totalScore = questions.reduce((sum, q) => sum + (Number(scores[q.id]) || 0), 0); const maxScore = questions.reduce((sum, q) => sum + (q.points ?? 0), 0); // The cutting point is read per the exam's configured evaluation method — // an AVERAGE or PERCENTAGE exam must not be graded as a raw sum. @@ -123,7 +134,7 @@ export function RecordResultModal({ : totalScore; const passed = effectiveScore >= exam.cuttingPoint; - const handleScoreChange = (questionId: string, value: number) => { + const handleScoreChange = (questionId: string, value: number | '') => { setScores((prev) => ({ ...prev, [questionId]: value })); }; @@ -136,21 +147,34 @@ export function RecordResultModal({ notify.error(t('result.recordModal.seafarerRequired')); return; } + // The same rules the API enforces (result_score_required, + // result_remark_required), caught here so the officer is told which box + // is empty before the request goes out. An empty mark is refused, never + // scored as zero — and never turned into a pass. + if (unscored.length) { + notify.error(t('result.recordModal.scoresRequired', { count: unscored.length })); + return; + } + if (!remark.trim()) { + notify.error(t('result.recordModal.reasonRequired')); + return; + } try { const breakdowns = questions.map((q) => ({ questionId: q.id, - score: scores[q.id] ?? 0, + score: Number(scores[q.id]), remark: questionRemarks[q.id] ?? '', })); - // The outcome is not sent: the API derives PASSED/FAILED from the - // session's evaluation method and cutting point, and stamps the - // examiner on the row (US-EXAM-011). The preview below shows what that - // computation will produce. + // The outcome is not typed in: the API derives PASSED/FAILED from the + // session's evaluation method and cutting point over the scores just + // entered, and stamps the examiner on the row (US-EXAM-011). The + // preview below shows exactly what that computation will produce, so + // the officer is confirming an explicit outcome, not guessing one. await createResult({ seafarerId: selectedSeafarerId, examId: exam.id, resultBreakdowns: breakdowns, - remark: remark ? { en: remark, am: '' } : undefined, + remark: { en: remark.trim(), am: '' }, }).unwrap(); notify.success(t('result.recordModal.saveSuccess')); setSelectedSeafarerId(null); @@ -161,14 +185,21 @@ export function RecordResultModal({ onClose(); } catch (error) { const key = extractErrorMessage(error, t('result.recordModal.saveError')); + const [code] = key.split(':'); notify.error( - key === 'candidate_not_registered' - ? 'This candidate is not registered for the session.' - : key.startsWith('candidate_not_present') - ? `No paper to mark — the register says ${key.split(':')[1] ?? ''}.` - : key === 'result_already_recorded' - ? 'A result has already been recorded for this candidate.' - : key, + code === 'candidate_not_registered' + ? t('result.recordModal.errors.notRegistered') + : code === 'candidate_not_present' + ? t('result.recordModal.errors.notPresent', { ruling: key.split(':')[1] ?? '' }) + : code === 'result_already_recorded' + ? t('result.recordModal.errors.alreadyRecorded') + : code === 'result_score_required' || + code === 'result_incomplete_breakdowns' || + code === 'result_breakdowns_required' + ? t('result.recordModal.scoresRequired', { count: unscored.length || 1 }) + : code === 'result_remark_required' + ? t('result.recordModal.reasonRequired') + : key, ); } }; @@ -191,6 +222,16 @@ export function RecordResultModal({ size="sm" required /> + {alreadyMarked > 0 && ( + }> + {t('result.recordModal.alreadyMarkedHint', { count: alreadyMarked })} + + )} + {sat.length > 0 && seafarerOptions.length === 0 && ( + }> + {t('result.recordModal.allMarked')} + + )} {selectedSeafarerId && questions.length > 0 && ( <> @@ -226,18 +267,30 @@ export function RecordResultModal({ + {unscored.length > 0 && ( + + {t('result.recordModal.scoresRequired', { count: unscored.length })} + + )} + setRemark(e.currentTarget.value)} size="sm" + required /> - 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 0921e9459..f2a1a0cf9 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 { ActionIcon, Menu } from '@mantine/core'; -import { IconDotsVertical, IconEye, IconSend, IconTrash } from '@tabler/icons-react'; +import { IconDotsVertical, IconEye, IconLock, 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,6 +21,10 @@ export function resultActionsColumn( label: t('result.columns.actions', 'Actions'), cell: ({ row }) => { const r = row.original; + // An engine-produced mark is immutable outside the appeal workflow, so + // the moderation/return/delete entries are not offered for it — the API + // refuses them anyway (result_locked_auto_graded). + const locked = r.autoGraded && !r.appealUnlockId; return ( @@ -29,16 +33,21 @@ export function resultActionsColumn( - } onClick={() => handlers.onViewDetail(r)}> - {t('result.action.viewEdit')} + : } + onClick={() => handlers.onViewDetail(r)} + > + {locked ? t('result.action.view') : t('result.action.viewEdit')} {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( <> - - handlers.onQc(r, 'moderate')}> - {t('result.review.moderate')} - - + {!locked && ( + + handlers.onQc(r, 'moderate')}> + {t('result.review.moderate')} + + + )} handlers.onQc(r, 'approve')}> {t('result.review.approve')} @@ -46,7 +55,7 @@ export function resultActionsColumn( )} - {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( + {!locked && (r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( } onClick={() => handlers.onPublish(r)} > - {t('result.review.publish')} + {t('result.review.publishOne')} + + + )} + {!locked && r.reviewStatus !== 'PUBLISHED' && ( + + + } + onClick={() => handlers.onDelete(r)} + > + {t('result.action.delete')} )} - - - } - 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 a39245b60..0d199113d 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -1,8 +1,8 @@ import { useState, useCallback, type ElementType } from 'react'; import { useTranslation } from 'react-i18next'; -import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core'; +import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput, Alert} from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react'; +import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend, IconLock} from '@tabler/icons-react'; import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import type { BilingualValue } from '@ema-platform/ui'; @@ -16,6 +16,7 @@ import { useModerateResultMutation, useApproveResultMutation, useReturnResultMutation, + usePublishResultMutation, usePublishExamResultsMutation, } from '../../api/result-api'; import { useGetExamsQuery } from '../../../exam/api/exam-api'; @@ -89,6 +90,7 @@ export function ResultPage() { const [approveResult, { isLoading: isApproving }] = useApproveResultMutation(); const [returnResult, { isLoading: isReturning }] = useReturnResultMutation(); const [publishResults, { isLoading: isPublishing }] = usePublishExamResultsMutation(); + const [publishOne, { isLoading: isPublishingOne }] = usePublishResultMutation(); const [searchQuery, setSearchQuery] = useState(''); const [examFilter, setExamFilter] = useState(null); @@ -175,7 +177,9 @@ export function ResultPage() { notify.error( key === 'result_locked_after_approval' ? t('result.review.lockedAfterApproval') - : key, + : key === 'result_locked_auto_graded' + ? t('result.review.autoGradedLocked') + : key, ); } finally { setDetailSaving(false); @@ -233,19 +237,33 @@ export function ResultPage() { } }; - /** Same publish call as handlePublish, but scoped to one row's exam — no page filter needed. */ + /** + * Applicant-level publication: this row's candidate only. The exam and every + * other candidate's mark are untouched — publishing A must never make B's + * result visible or flip the session everyone shares. + */ const handleConfirmPublish = async () => { if (!publishTarget) return; try { - const outcome = await publishResults(publishTarget.examId).unwrap(); - notify.success(t('result.review.publishedCount', outcome)); + await publishOne(publishTarget.id).unwrap(); + notify.success(t('result.review.publishedOne')); closePublish(); setPublishTarget(null); } catch (error) { - notify.error(extractErrorMessage(error, t('result.review.error'))); + const key = extractErrorMessage(error, t('result.review.error')); + notify.error( + key === 'result_not_approved' + ? t('result.review.publishNeedsApproval') + : key === 'result_already_published' + ? t('result.review.alreadyPublished') + : key, + ); } }; + // The engine's mark is read-only here: only an upheld appeal reopens it. + const detailLocked = Boolean(detailResult?.autoGraded && !detailResult?.appealUnlockId); + const handleDetailClose = () => { closeDetail(); setDetailBreakdowns([]); @@ -413,6 +431,11 @@ export function ResultPage() { {t(`result.review.${detailResult.reviewStatus}`)} + {detailResult.autoGraded && ( + }> + {t('result.review.autoGraded')} + + )} {detailResult.preModerationScore != null && ( {t('result.review.originalScore')}: {detailResult.preModerationScore} @@ -422,6 +445,12 @@ export function ResultPage() { + {detailLocked && ( + }> + {t('result.review.autoGradedLocked')} + + )} + { const updated = [...detailBreakdowns]; updated[i] = { ...updated[i], score: Number(e.currentTarget.value) }; @@ -474,6 +505,8 @@ export function ResultPage() { size="xs" placeholder="Optional" value={b.remark ?? ''} + readOnly={detailLocked} + disabled={detailLocked} onChange={(e) => { const updated = [...detailBreakdowns]; updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined }; @@ -501,6 +534,7 @@ export function ResultPage() { onClick={handleDetailSave} size="sm" loading={detailSaving} + disabled={detailLocked} leftSection={} > {t('result.save')} @@ -568,16 +602,19 @@ export function ResultPage() { - + - {t('result.review.publishConfirmText', { + {t('result.review.publishOneConfirmText', { + candidate: publishTarget?.seafarer + ? `${publishTarget.seafarer.firstName} ${publishTarget.seafarer.lastName}` + : publishTarget?.seafarerId.slice(0, 8) ?? '', exam: publishTarget ? getExamTitle(publishTarget.examId) : '', })} - diff --git a/apps/backoffice/src/app/features/result/types/result.ts b/apps/backoffice/src/app/features/result/types/result.ts index 4015192f8..1245dc1c4 100644 --- a/apps/backoffice/src/app/features/result/types/result.ts +++ b/apps/backoffice/src/app/features/result/types/result.ts @@ -62,6 +62,13 @@ export interface Result { remark: { en: string; am: string } | null; status: ExamResultStatus; reviewStatus: ResultReviewStatus; + /** + * The exam engine produced this mark from the candidate's own answers and + * the configured answer key. Locked against every ordinary edit — only an + * upheld appeal reopens it — so the score inputs are read-only for it. + */ + autoGraded: boolean; + appealUnlockId?: string | null; markedById: string | null; markedAt: string | null; preModerationScore: number | null; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 2ab375c34..a422c3bf2 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -473,6 +473,8 @@ export const am: Translations = { form: "ቅጽ", venue: "ቦታ", date: "ቀን", + window: "የፈተና ሰዓት", + allDay: "ቀኑን ሙሉ", administration: "አስተዳደር", evaluation: "ግምገማ", selection: "ምርጫ", @@ -498,6 +500,11 @@ export const am: Translations = { directionAm: "መመሪያ (አማርኛ)", directionAmPlaceholder: "መመሪያ በአማርኛ", examDate: "የፈተና ቀን", + startTime: "የመጀመሪያ ሰዓት", + endTime: "የመጨረሻ ሰዓት", + windowHint: + "አማራጭ። ተፈታኞች በፈተናው ቀን ከመጀመሪያ ሰዓት በፊት ወይም ከመጨረሻ ሰዓት በኋላ መጀመር አይችሉም (የአዲስ አበባ ሰዓት)። ቀኑን ሙሉ ክፍት ለማድረግ ባዶ ይተዉ።", + windowInvalid: "የመጨረሻ ሰዓት ከመጀመሪያ ሰዓት በኋላ መሆን አለበት።", venue: "ቦታ", venuePlaceholder: "የፈተና ቦታ", timeAllowed: "የተፈቀደ ጊዜ", @@ -556,7 +563,6 @@ export const am: Translations = { COMPLETED: "ተጠናቋል", CANCELLED: "ተሰርዟል", POSTPONED: "ተላልፏል", - PUBLISHED: "ታትሟል", }, type: { WRITTEN: "ጽሑፍ", @@ -597,6 +603,14 @@ export const am: Translations = { regraded: "ውጤት ከተመዘገበው ሙከራ ተፈጥሯል።", regradeNotEligible: "በራስ-ሰር ሊገመገም አይችልም፦ {{reason}}። ውጤት መዝግብ ተጠቀም።", regradeError: "ይህን ሙከራ እንደገና መገምገም አልተቻለም።", + result: "ውጤት", + noResult: "አልተመዘነም", + engineMarked: "በፈተና ሞተሩ ከመልስ ቁልፉ የተመዘነ — ተቆልፏል። ግምገማ፦ {{review}}።", + examinerMarked: "በፈታኝ የተመዘነ። ግምገማ፦ {{review}}።", + outcome: { + PASSED: "አልፏል", + FAILED: "አላለፈም", + }, }, attendance: { REGISTERED: "አልተጠራም", @@ -646,6 +660,100 @@ export const am: Translations = { paperLocked: "ወረቀቱ ተቆልፏል", paperLockedHint: "ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።", + errors: { + scoringLocked: + "ለዚህ ፈተና {{count}} ውጤት(ዎች) አስቀድመው ጸድቀዋል ወይም ወጥተዋል። የማለፊያ ነጥቡና የግምገማ ዘዴው ሊቀየሩ አይችሉም።", + }, + questionErrors: { + subjectMismatch: "አንድ ወይም ከዚያ በላይ ጥያቄዎች የዚህ ፈተና ትምህርት አይደሉም።", + notFound: "አንድ ወይም ከዚያ በላይ ጥያቄዎች አልተገኙም።", + }, + questionsMenu: { + add: "ጥያቄ ጨምር", + fromBank: "ከጥያቄ ባንክ ፍጠር", + importExcel: "ከExcel አስገባ", + fromScratch: "አዲስ ጥያቄ ጨምር", + managePaper: "ሙሉ ወረቀቱን እንደገና መድብ", + }, + bank: { + title: "ከጥያቄ ባንክ ጨምር", + hint: "ለዚህ ትምህርት የጸደቁና በወረቀቱ ላይ ያልተካተቱ ጥያቄዎች። የተመረጡት ከተመደቡት ጥያቄዎች በኋላ ይጨመራሉ።", + search: "ጥያቄዎችን ፈልግ…", + empty: "ለዚህ ትምህርት ሊጨመር የሚችል የጸደቀ ጥያቄ የለም።", + add: "{{count}} ወደዚህ ፈተና ጨምር", + added: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ተጨምረዋል", + }, + newQuestion: { + title: "ለዚህ ፈተና አዲስ ጥያቄ ጨምር", + hint: "ጥያቄው በዚህ ፈተና ትምህርት ሥር ተፈጥሮ በአንድ እርምጃ በወረቀቱ ላይ ይቀመጣል። እንደ የጸደቀ ጥያቄ ወደ ባንኩ ይገባል።", + titleEn: "ጥያቄ (እንግሊዝኛ)", + titleAm: "ጥያቄ (አማርኛ)", + form: "ዓይነት", + points: "ነጥብ", + options: "አማራጮች", + optionEn: "አማራጭ {{number}} (እንግሊዝኛ)", + optionAm: "አማራጭ {{number}} (አማርኛ)", + correct: "ትክክል", + addOption: "አማራጭ ጨምር", + create: "ፍጠርና ወደ ፈተና ጨምር", + created: "ጥያቄው ተፈጥሮ ወደ ወረቀቱ ተጨምሯል", + fillRequired: "የጥያቄውን ጽሑፍ፣ ዓይነትና ከዜሮ በላይ ነጥብ ያስገቡ።", + needTwo: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል።", + needCorrect: "ቢያንስ አንድ አማራጭ ትክክል ብለው ይምረጡ።", + textRequired: "እያንዳንዱ አማራጭ የእንግሊዝኛ ጽሑፍ ያስፈልገዋል።", + }, + import: { + title: "ጥያቄዎችን ከExcel አስገባ", + hint: "በእያንዳንዱ ረድፍ አንድ ጥያቄ ያለውን ፋይል ይጫኑ፣ ያረጋግጡ፣ ቅድመ እይታውን ይመልከቱ፣ ከዚያ ያስገቡ። አንድ ረድፍ ችግር ካለው ምንም አይገባም።", + template: "ቅጹን አውርድ", + file: "የExcel ፋይል (.xlsx)", + validate: "አረጋግጥ", + import: "አስገባ", + preview: "ቅድመ እይታ — {{count}} ጥያቄ(ዎች)", + valid: "ለማስገባት ዝግጁ", + errors: "{{count}} ችግር(ዎች) ተገኝተዋል", + nothingImported: "ከላይ ያሉትን ረድፎች አስተካክለው እንደገና ያረጋግጡ። ምንም አልገባም።", + imported: "{{count}} ጥያቄ(ዎች) ወደ ወረቀቱ ገብተዋል", + row: "ረድፍ", + column: "አምድ", + problem: "ችግር", + question: "ጥያቄ", + type: "ዓይነት", + points: "ነጥብ", + options: "አማራጮች", + errorKeys: { + question_text_required: "የጥያቄው ጽሑፍ (እንግሊዝኛ) ያስፈልጋል።", + invalid_question_type: "ዓይነት CHOICE ወይም ESSAY መሆን አለበት።", + invalid_points: "ነጥብ ከዜሮ የሚበልጥ ቁጥር መሆን አለበት።", + options_required: "የምርጫ ጥያቄ ቢያንስ ሁለት አማራጮች ያስፈልጉታል (option_a_en, option_b_en, …)።", + correct_answer_required: "ትክክለኛውን አማራጭ ፊደል በ“correct” አምድ ውስጥ ያመልክቱ።", + correct_answer_invalid: "የ“correct” አምድ ያልተሞላ አማራጭን ያመለክታል።", + duplicate_in_file: "ከረድፍ {{detail}} ጋር አንድ ዓይነት ጥያቄ።", + duplicate_in_bank: "ይህ ጥያቄ ለዚህ ትምህርት በባንኩ ውስጥ አስቀድሞ አለ።", + missing_columns: "የሚያስፈልጉ አምድ(ዎች) ጠፍተዋል፦ {{detail}}።", + too_many_rows: "በአንድ ጊዜ ቢበዛ {{detail}} ረድፎች ማስገባት ይቻላል።", + no_questions_in_file: "ሉሁ የጥያቄ ረድፍ የለውም።", + invalid_excel_file: "ፋይሉ ሊነበብ የሚችል .xlsx አይደለም።", + }, + }, + metrics: { + section: "የፈተና መጠበቂያ መለኪያዎች", + hint: "ከምዝገባ፣ ከተገኝነትና ከፈተና መጀመሪያ ጊዜ መዝገቦች የተገኘ። ለትንተና ብቻ — ምዝገባን፣ ተገኝነትን፣ ውጤትን ወይም ሰርተፍኬትን አይቀይርም።", + candidates: "{{count}} ተመዝግበዋል", + attended: "{{count}} ተገኝተዋል", + started: "{{count}} ጀምረዋል", + registrationToScheduled: "ምዝገባ → የተያዘ መጀመሪያ", + scheduledToAttendance: "የተያዘ መጀመሪያ → መግባት", + attendanceToExamStart: "መግባት → የፈተና መጀመሪያ", + scheduledToExamStart: "የተያዘ መጀመሪያ → የፈተና መጀመሪያ", + average: "አማካይ", + min: "ዝቅተኛ", + max: "ከፍተኛ", + count: "ተፈታኞች", + minutes: "{{value}} ደቂቃ", + hours: "{{value}} ሰዓት", + days: "{{value}} ቀን", + }, }, country: { @@ -954,6 +1062,19 @@ export const am: Translations = { remark: "ማስታወሻ", remarkOptional: "ማስታወሻ (አማራጭ)", remarkPlaceholder: "የኦፊሰር ማስታወሻ", + reason: "ምክንያት / አስተያየት", + reasonPlaceholder: "ተፈታኙ እነዚህ ነጥቦች የተሰጡበት ምክንያት — ግዴታ", + reasonRequired: "በእጅ ለሚመዘገብ ውጤት ምክንያት ያስፈልጋል።", + scorePlaceholder: "ነጥብ", + scoresRequired: "{{count}} ጥያቄ(ዎች) እስካሁን ነጥብ የላቸውም። እያንዳንዱ ጥያቄ ነጥብ ያስፈልገዋል — ባዶ ሳጥን ዜሮ አይደለም።", + alreadyMarkedHint: + "በዚህ ፈተና {{count}} ተፈታኝ(ዎች) አስቀድመው ውጤት አላቸውና አልተዘረዘሩም — የፈተና ሞተሩ ወይም ፈታኝ ወረቀታቸውን መዝኗል። እነዚያን ለመመልከት የፈተና ውጤቶችን ይጠቀሙ።", + allMarked: "በዚህ ፈተና የተፈተኑ ሁሉ አስቀድመው ውጤት አላቸው። በእጅ የሚመዘገብ ምንም የለም።", + errors: { + notRegistered: "ይህ ተፈታኝ ለዚህ ፈተና አልተመዘገበም።", + notPresent: "የሚመዘን ወረቀት የለም — መዝገቡ {{ruling}} ይላል።", + alreadyRecorded: "ለዚህ ተፈታኝ ውጤት አስቀድሞ ተመዝግቧል።", + }, totalScore: "ጠቅላላ ውጤት", passMark: "ማለፊያ ውጤት", status: "ሁኔታ", @@ -970,6 +1091,7 @@ export const am: Translations = { }, action: { viewEdit: "ተመልከት / አስተካክል", + view: "ተመልከት", delete: "ሰርዝ", }, search: { @@ -1019,6 +1141,15 @@ export const am: Translations = { publishNeedsExam: "ውጤቶችን ለማውጣት መጀመሪያ በፈተና ያጣሩ።", publishConfirmText: "ይህ ለ{{exam}} የጸደቁትን ሁሉንም ውጤቶች ያወጣል — ይህን ብቻ አይደለም — እና እያንዳንዱን ተፈታኝ ያሳውቃል። ይቀጥል?", + publishOne: "ይህን ውጤት አውጣ", + publishOneConfirmText: + "የ{{candidate}} የ{{exam}} ውጤት ይውጣ? ይህ ተፈታኝ ብቻ ይነገረዋል፣ የእሱ/የእሷ ማመልከቻ ብቻ ይቀጥላል — ፈተናውና ሌሎች ተፈታኞች አይነኩም።", + publishedOne: "ውጤቱ ለተፈታኙ ወጥቷል", + publishNeedsApproval: "ውጤት ከመውጣቱ በፊት መጽደቅ አለበት።", + alreadyPublished: "ይህ ውጤት አስቀድሞ ወጥቷል።", + autoGraded: "በራስ-ሰር የተገመገመ", + autoGradedLocked: + "ይህ ውጤት በፈተና ሞተሩ ከተፈታኙ መልሶችና ከመልስ ቁልፉ ተሰልቷል። ተቆልፏል፦ ነጥቦች ሊስተካከሉ፣ ሊመረመሩ ወይም ሊሰረዙ አይችሉም። የተቀበለ ይግባኝ ብቻ ለድጋሚ እርማት ይከፍተዋል።", lockedAfterApproval: "ይህ ውጤት ጸድቋል፤ ማስተካከል አይቻልም። መጀመሪያ ወደ ፈታኙ ይመልሱት።", originalScore: "የፈታኙ ጠቅላላ", derivedStatus: "ውጤት (ከማለፊያ ነጥብ የተገኘ)", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 68d4ffc5b..b110ae9ba 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -480,6 +480,8 @@ export const en = { form: 'Form', venue: 'Venue', date: 'Date', + window: 'Session time', + allDay: 'All day', administration: 'Administration', evaluation: 'Evaluation', selection: 'Selection', @@ -505,6 +507,11 @@ export const en = { directionAm: 'Direction (Amharic)', directionAmPlaceholder: 'መመሪያ በአማርኛ', examDate: 'Exam Date', + startTime: 'Start time', + endTime: 'End time', + windowHint: + 'Optional. Candidates cannot start before the start time, or after the end time, on the exam date (Addis Ababa time). Leave blank to open the whole day.', + windowInvalid: 'The end time must be after the start time.', venue: 'Venue', venuePlaceholder: 'Exam venue', timeAllowed: 'Time Allowed', @@ -562,7 +569,6 @@ export const en = { COMPLETED: 'Completed', CANCELLED: 'Cancelled', POSTPONED: 'Postponed', - PUBLISHED: 'Published', }, type: { WRITTEN: 'Written', @@ -603,6 +609,14 @@ export const en = { regraded: 'Result created from the graded attempt.', regradeNotEligible: 'Not auto-gradable: {{reason}}. Use Record Result instead.', regradeError: 'Could not regrade this attempt.', + result: 'Result', + noResult: 'Not marked', + engineMarked: 'Marked by the exam engine from the answer key — locked. Review: {{review}}.', + examinerMarked: 'Marked by an examiner. Review: {{review}}.', + outcome: { + PASSED: 'Passed', + FAILED: 'Failed', + }, }, attendance: { REGISTERED: 'Not called', @@ -653,6 +667,100 @@ export const en = { paperLocked: 'Paper locked', paperLockedHint: '{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.', + errors: { + scoringLocked: + '{{count}} result(s) for this session are already approved or published. The pass mark and evaluation method cannot change under them.', + }, + questionErrors: { + subjectMismatch: 'One or more questions belong to a different subject than this exam.', + notFound: 'One or more questions could not be found.', + }, + questionsMenu: { + add: 'Add question', + fromBank: 'Create from question bank', + importExcel: 'Import from Excel', + fromScratch: 'Add new question', + managePaper: 'Reassign whole paper', + }, + bank: { + title: 'Add from the question bank', + hint: 'Approved questions for this subject that are not yet on the paper. Selected items are added after the questions already assigned.', + search: 'Search questions…', + empty: 'No approved questions for this subject are available to add.', + add: 'Add {{count}} to this exam', + added: '{{count}} question(s) added to the paper', + }, + newQuestion: { + title: 'Add a new question to this exam', + hint: 'The question is created under this exam’s subject and placed on its paper in one step. It joins the bank as an approved item.', + titleEn: 'Question (English)', + titleAm: 'Question (Amharic)', + form: 'Type', + points: 'Points', + options: 'Options', + optionEn: 'Option {{number}} (English)', + optionAm: 'Option {{number}} (Amharic)', + correct: 'Correct', + addOption: 'Add option', + create: 'Create and add to exam', + created: 'Question created and added to the paper', + fillRequired: 'Enter the question text, type and a positive number of points.', + needTwo: 'A choice question needs at least two options.', + needCorrect: 'Mark at least one option as correct.', + textRequired: 'Every option needs its English text.', + }, + import: { + title: 'Import questions from Excel', + hint: 'Upload a workbook with one question per row, validate it, review the preview, then import. If any row has a problem, nothing is imported.', + template: 'Download the template', + file: 'Excel file (.xlsx)', + validate: 'Validate', + import: 'Import', + preview: 'Preview — {{count}} question(s)', + valid: 'Ready to import', + errors: '{{count}} problem(s) found', + nothingImported: 'Fix the rows above and validate again. Nothing has been imported.', + imported: 'Imported {{count}} question(s) onto the paper', + row: 'Row', + column: 'Column', + problem: 'Problem', + question: 'Question', + type: 'Type', + points: 'Points', + options: 'Options', + errorKeys: { + question_text_required: 'The question text (English) is required.', + invalid_question_type: 'Type must be CHOICE or ESSAY.', + invalid_points: 'Points must be a number greater than zero.', + options_required: 'A CHOICE question needs at least two options (option_a_en, option_b_en, …).', + correct_answer_required: 'Mark the correct option letter(s) in the “correct” column.', + correct_answer_invalid: 'The “correct” column names an option that is not filled in.', + duplicate_in_file: 'Same question as row {{detail}}.', + duplicate_in_bank: 'This question already exists in the bank for this subject.', + missing_columns: 'Required column(s) missing: {{detail}}.', + too_many_rows: 'At most {{detail}} rows can be imported at once.', + no_questions_in_file: 'The sheet has no question rows.', + invalid_excel_file: 'The file is not a readable .xlsx workbook.', + }, + }, + metrics: { + section: 'Exam wait metrics', + hint: 'Derived from the registration, attendance and exam-start timestamps already on record. Analytical only — nothing here changes a registration, attendance, result or certificate.', + candidates: '{{count}} registered', + attended: '{{count}} checked in', + started: '{{count}} started', + registrationToScheduled: 'Registration → scheduled start', + scheduledToAttendance: 'Scheduled start → check-in', + attendanceToExamStart: 'Check-in → exam start', + scheduledToExamStart: 'Scheduled start → exam start', + average: 'average', + min: 'min', + max: 'max', + count: 'candidates', + minutes: '{{value}} min', + hours: '{{value}} h', + days: '{{value}} d', + }, }, country: { @@ -963,6 +1071,19 @@ export const en = { remark: 'Remark', remarkOptional: 'Remark (optional)', remarkPlaceholder: 'Officer remarks', + reason: 'Reason / remarks', + reasonPlaceholder: 'Why the candidate is awarded these marks — required', + reasonRequired: 'A reason is required for a manually recorded result.', + scorePlaceholder: 'Mark', + scoresRequired: '{{count}} question(s) have no mark yet. Every question needs a mark — an empty box is not a zero.', + alreadyMarkedHint: + '{{count}} candidate(s) on this session already have a result and are not listed — the exam engine or an examiner has marked their paper. Use Exam Results to review those.', + allMarked: 'Every candidate who sat this session already has a result. There is nothing left to record by hand.', + errors: { + notRegistered: 'This candidate is not registered for the session.', + notPresent: 'No paper to mark — the register says {{ruling}}.', + alreadyRecorded: 'A result has already been recorded for this candidate.', + }, totalScore: 'Total Score', passMark: 'Pass Mark', status: 'Status', @@ -979,6 +1100,7 @@ export const en = { }, action: { viewEdit: 'View / Edit', + view: 'View', delete: 'Delete', }, search: { @@ -1029,6 +1151,15 @@ export const en = { 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?', + publishOne: 'Publish this result', + publishOneConfirmText: + 'Publish the result of {{candidate}} for {{exam}}? Only this candidate is notified and only their application advances — the exam and every other candidate are unaffected.', + publishedOne: 'Result published to the candidate', + publishNeedsApproval: 'A result must be approved before it can be published.', + alreadyPublished: 'This result has already been published.', + autoGraded: 'Auto-graded', + autoGradedLocked: + 'This mark was calculated by the exam engine from the candidate’s answers and the answer key. It is locked: scores cannot be edited, moderated or deleted. Only an upheld appeal reopens it for re-marking.', lockedAfterApproval: 'This result is approved and can no longer be edited. Return it to the examiner first.', originalScore: 'Examiner total', diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 6ab70f6f7..15db03b47 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -123,6 +123,7 @@ const EXAM_STAGE_LABELS: Record = { EXAM_PAID: 'Exam Paid', REGISTERED: 'Exam Scheduled', ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed', + NOT_SITTING: 'Not Sitting — See Exam Registration', SITTING: 'Exam In Progress', UNDER_EVALUATION: 'Exam Completed — Under Evaluation', PASSED: 'Passed — Certificate Fee Due', diff --git a/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx index e0bfa2c87..ec42010a9 100644 --- a/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx +++ b/apps/portal/src/app/features/exam-attempt/components/ExamInstructions.tsx @@ -2,6 +2,7 @@ import { Alert, Badge, Button, Card, Group, Stack, Text, Title } from '@mantine/ import { IconAlertCircle, IconClock, IconInfoCircle, IconPlayerPlay } from '@tabler/icons-react'; import type { Bilingual } from '@ema-platform/api'; import type { EstimatedTime, RegistrationWithExam } from '../types/exam-attempt'; +import { examWindowState, shortTime, windowLabel } from '../../exams/exam-window'; function formatDuration(time: EstimatedTime | null | undefined): string { if (!time) return 'Not configured'; @@ -27,7 +28,10 @@ export function ExamInstructions({ onStart: () => void; }) { const exam = registration.exam; - const canStart = exam?.status === 'ACTIVE'; + // The portal's reading of the session window, so the page says why the + // button is shut. The server decides on its own clock regardless. + const window = exam ? examWindowState(exam) : 'OPEN'; + const canStart = exam?.status === 'ACTIVE' && window === 'OPEN'; return ( @@ -42,7 +46,11 @@ export function ExamInstructions({ Session date - {showDate(exam?.date)}{exam?.venue ? ` · ${exam.venue}` : ''} + + {showDate(exam?.date)} + {exam && windowLabel(exam) ? ` · ${windowLabel(exam)}` : ''} + {exam?.venue ? ` · ${exam.venue}` : ''} + Duration @@ -72,7 +80,18 @@ export function ExamInstructions({ the exam ends the moment the deadline passes, whether or not you have submitted. - {!canStart && ( + {exam?.status === 'ACTIVE' && window === 'NOT_STARTED' && ( + + This session opens on {showDate(exam.date)} at {shortTime(exam.startTime) ?? '00:00'}. + The exam cannot be started before then. + + )} + {exam?.status === 'ACTIVE' && window === 'CLOSED' && ( + + This session's start window has closed. + + )} + {exam?.status !== 'ACTIVE' && ( This session is not currently open for candidates to begin. diff --git a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts index baa445600..88e5affa5 100644 --- a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts +++ b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts @@ -217,12 +217,21 @@ export function useExamAttempt(examId: string | undefined) { seedFrom(result); } catch (error) { const key = extractErrorMessage(error, 'Could not start the exam.'); + const [code, detail = ''] = key.split(':'); notify.error( - key === 'attendance_not_confirmed' + code === 'attendance_not_confirmed' ? 'An invigilator must confirm you are present before this exam opens.' - : key === 'candidate_not_present' + : code === 'candidate_not_present' ? 'Your attendance record does not permit sitting this examination.' - : key, + : code === 'exam_not_started' + ? 'This examination has not opened yet. It can only be started at its scheduled date and time.' + : code === 'exam_window_closed' + ? "This session's start window has closed." + : code === 'exam_prerequisite_not_met' + ? `You are not eligible to sit this ${detail || ''} examination — no application of yours is awaiting it.` + : code === 'exam_prerequisite_missing' + ? `A prerequisite certificate is not held: ${detail.split(',').join(', ')}.` + : key, ); } }, [examId, startTrigger, seedFrom]); diff --git a/apps/portal/src/app/features/exam-attempt/types/exam-attempt.ts b/apps/portal/src/app/features/exam-attempt/types/exam-attempt.ts index c11713ac5..ce7853c69 100644 --- a/apps/portal/src/app/features/exam-attempt/types/exam-attempt.ts +++ b/apps/portal/src/app/features/exam-attempt/types/exam-attempt.ts @@ -70,6 +70,9 @@ export interface RegistrationWithExam { title: Bilingual; direction?: Bilingual; date: string; + /** `HH:MM[:SS]` the session opens on `date`; null means the start of the day. */ + startTime?: string | null; + endTime?: string | null; venue: string | null; status: string; givenTime: EstimatedTime | null; diff --git a/apps/portal/src/app/features/exams/exam-window.spec.ts b/apps/portal/src/app/features/exams/exam-window.spec.ts new file mode 100644 index 000000000..79b754df9 --- /dev/null +++ b/apps/portal/src/app/features/exams/exam-window.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { examWindowState, scheduledStartOf, windowLabel, zonedInstant } from './exam-window'; + +/** + * The portal's reading of the session window must agree with the server's + * (emaapi exam-window.ts): Addis Ababa is UTC+3, so a 10:00 sitting on + * 10 September opens at 07:00Z whatever the device's zone. + */ +describe('exam-window (portal)', () => { + const exam = { date: '2026-09-10', startTime: '10:00:00', endTime: '12:00:00' }; + + it('converts the configured wall-clock time through the authority timezone', () => { + expect(scheduledStartOf(exam).toISOString()).toBe('2026-09-10T07:00:00.000Z'); + }); + + it('is NOT_STARTED the evening before, even though the date is tomorrow', () => { + expect(examWindowState(exam, zonedInstant('2026-09-09', '17:00'))).toBe('NOT_STARTED'); + }); + + it('opens at the configured minute and closes after the end', () => { + expect(examWindowState(exam, zonedInstant('2026-09-10', '09:59'))).toBe('NOT_STARTED'); + expect(examWindowState(exam, zonedInstant('2026-09-10', '10:00'))).toBe('OPEN'); + expect(examWindowState(exam, zonedInstant('2026-09-10', '12:00'))).toBe('OPEN'); + expect(examWindowState(exam, zonedInstant('2026-09-10', '12:01'))).toBe('CLOSED'); + }); + + it('treats a session with no times as open all day', () => { + const allDay = { date: '2026-09-10', startTime: null, endTime: null }; + expect(examWindowState(allDay, zonedInstant('2026-09-09', '23:59'))).toBe('NOT_STARTED'); + expect(examWindowState(allDay, zonedInstant('2026-09-10', '00:00'))).toBe('OPEN'); + expect(examWindowState(allDay, zonedInstant('2026-09-10', '23:59'))).toBe('OPEN'); + }); + + it('labels the window for display', () => { + expect(windowLabel(exam)).toBe('10:00 – 12:00'); + expect(windowLabel({ date: '2026-09-10', startTime: '10:00' })).toBe('from 10:00'); + expect(windowLabel({ date: '2026-09-10' })).toBeNull(); + }); +}); diff --git a/apps/portal/src/app/features/exams/exam-window.ts b/apps/portal/src/app/features/exams/exam-window.ts new file mode 100644 index 000000000..3f665b076 --- /dev/null +++ b/apps/portal/src/app/features/exams/exam-window.ts @@ -0,0 +1,84 @@ +/** + * Where "now" sits relative to a session's configured window — the portal's + * own reading, for disabling "Take exam" and explaining why, before the + * candidate hits the server's `exam_not_started` refusal. + * + * Display convenience only: the backend decides on its own clock + * (ExamAttemptService, exam-window.ts) and this mirrors its arithmetic. A + * device with a wrong clock gets a wrong button state, never a wrong exam. + */ +export const EXAM_TIMEZONE = 'Africa/Addis_Ababa'; + +export interface ExamScheduleLike { + date: string; + startTime?: string | null; + endTime?: string | null; +} + +export type ExamWindowState = 'NOT_STARTED' | 'OPEN' | 'CLOSED'; + +function offsetMinutesAt(instant: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(instant); + const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0); + const asUtc = Date.UTC( + read('year'), + read('month') - 1, + read('day'), + read('hour'), + read('minute'), + read('second'), + ); + return Math.round((asUtc - instant.getTime()) / 60_000); +} + +/** The instant that `YYYY-MM-DD` + `HH:MM[:SS]` denotes in `timeZone`. */ +export function zonedInstant( + dateString: string, + time: string, + timeZone: string = EXAM_TIMEZONE, +): Date { + const [year, month, day] = dateString.slice(0, 10).split('-').map(Number); + const [hour, minute, second = 0] = time.split(':').map(Number); + const naive = Date.UTC(year, month - 1, day, hour, minute, second); + const first = naive - offsetMinutesAt(new Date(naive), timeZone) * 60_000; + return new Date(naive - offsetMinutesAt(new Date(first), timeZone) * 60_000); +} + +export function scheduledStartOf(exam: ExamScheduleLike): Date { + return zonedInstant(exam.date, exam.startTime?.trim() || '00:00:00'); +} + +export function scheduledEndOf(exam: ExamScheduleLike): Date { + return zonedInstant(exam.date, exam.endTime?.trim() || '23:59:59'); +} + +export function examWindowState(exam: ExamScheduleLike, now: Date = new Date()): ExamWindowState { + if (now.getTime() < scheduledStartOf(exam).getTime()) return 'NOT_STARTED'; + if (now.getTime() > scheduledEndOf(exam).getTime()) return 'CLOSED'; + return 'OPEN'; +} + +/** "10:00" from a stored "10:00:00", or null when the session has no time. */ +export function shortTime(time: string | null | undefined): string | null { + if (!time) return null; + return time.slice(0, 5); +} + +/** "10:00 – 12:00", "from 10:00", "until 12:00", or null when nothing is configured. */ +export function windowLabel(exam: ExamScheduleLike): string | null { + const start = shortTime(exam.startTime); + const end = shortTime(exam.endTime); + if (start && end) return `${start} – ${end}`; + if (start) return `from ${start}`; + if (end) return `until ${end}`; + return null; +} diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx index 652e72fc3..2f34faa87 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -10,6 +10,7 @@ import type { MyRegistration, MyResult, } from './index'; +import { examWindowState, shortTime, windowLabel } from '../../exam-window'; const ATTENDANCE_COLOR: Record = { REGISTERED: 'gray', @@ -55,7 +56,18 @@ export function registrationColumns( }, { header: t('exams.columns.date'), - cell: ({ row }) => deps.showDate(row.original.exam?.date), + cell: ({ row }) => { + const exam = row.original.exam; + const label = exam ? windowLabel(exam) : null; + return ( + + {deps.showDate(exam?.date)} + {label ? ( + {` · ${label}`} + ) : null} + + ); + }, }, { header: t('exams.columns.venue'), @@ -142,6 +154,29 @@ export function registrationColumns( ); } if (exam?.status !== 'ACTIVE') return null; + // The session window, on the portal's own clock — a courtesy so the + // button says why it is shut. The server refuses on its clock + // regardless (exam_not_started), whatever this device believes. + const window = attemptStatus === 'IN_PROGRESS' ? 'OPEN' : examWindowState(exam); + if (window === 'NOT_STARTED') { + return ( + + + {t('exams.columns.notYetOpen', { + date: deps.showDate(exam.date), + time: shortTime(exam.startTime) ?? '00:00', + })} + + + ); + } + if (window === 'CLOSED') { + return ( + + {t('exams.columns.windowClosed')} + + ); + } return (