diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 5ce7a4ab5..4abe06117 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -122,24 +122,26 @@ export function ExamDetailPage() { // Only approved bank items may go on a paper (US-EXAM-003), so the picker // must not offer drafts or retired questions either. // - // Form restriction mirrors the backend's assertUsable: ONLINE exams are - // CHOICE-only (auto-grading needs it), OFFLINE exams have no form - // restriction at all — mixing ESSAY and CHOICE by hand (exam.form - // "BOTH") is the sanctioned, only way to build a mixed paper. Matching - // `q.form === exam.form` here used to hide every question once "BOTH" - // became a real exam.form value, since no question itself is "BOTH". + // Filters on exam.form alone, not administrationMethod: the backend no + // longer restricts ONLINE to CHOICE (ExamService no longer has an + // assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of + // truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH + // describes a mixed paper — a question itself is never "BOTH" (see + // QuestionForm), so an equality check against it would match nothing and + // silently offer zero questions; skipped the same way the backend's own + // random draw does (ExamService.selectRandomQuestions). const eligibleQuestions = useMemo(() => { if (!exam) return []; return allQuestions .filter( (q) => q.certificationId === exam.certificationId && - q.status === 'APPROVED' && - (exam.administrationMethod !== 'ONLINE' || q.form === 'CHOICE'), + (exam.form === 'BOTH' || q.form === exam.form) && + q.status === 'APPROVED', ) .map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points })); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allQuestions, exam?.certificationId, exam?.administrationMethod]); + }, [allQuestions, exam?.certificationId, exam?.form]); if (isLoading) return ; diff --git a/apps/backoffice/src/app/features/exam/types/exam.ts b/apps/backoffice/src/app/features/exam/types/exam.ts index 9ca4676d4..52adbd6fa 100644 --- a/apps/backoffice/src/app/features/exam/types/exam.ts +++ b/apps/backoffice/src/app/features/exam/types/exam.ts @@ -3,6 +3,11 @@ import type { EstimatedTime } from "../../question/types/question"; import type { QuestionForm } from "../../question/types/question"; export type { QuestionForm }; +/** + * The paper's own form — unlike a question's (QuestionForm), an exam may mix + * both: BOTH means a mixed paper, resolved per-question against each + * question's own ESSAY/CHOICE form. Mirrors backend EExamForm. + */ export type ExamForm = QuestionForm | "BOTH"; export type ExamType = "WRITTEN" | "ORAL"; @@ -10,12 +15,7 @@ export type ExamAdministrationMethod = "OFFLINE" | "ONLINE"; export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE"; export type ExamSelectionMethod = "MANUAL" | "RANDOM"; export type ExamStatus = - | "PENDING" - | "ACTIVE" - | "COMPLETED" - | "CANCELLED" - | "POSTPONED" - | "PUBLISHED"; + "PENDING" | "ACTIVE" | "COMPLETED" | "CANCELLED" | "POSTPONED" | "PUBLISHED"; /** Only populated when the exam is fetched with `?i=questions,questions.options`. */ export interface QuestionOptionBrief { @@ -103,19 +103,14 @@ export interface RandomQuestionsPayload { /** What the invigilator recorded on the day (US-EXAM-009). */ export type AttendanceStatus = - | 'REGISTERED' - | 'PRESENT' - | 'ABSENT' - | 'LATE' - | 'WITHDRAWN' - | 'DISQUALIFIED'; + "REGISTERED" | "PRESENT" | "ABSENT" | "LATE" | "WITHDRAWN" | "DISQUALIFIED"; export interface ExamRegistration { id: string; examId: string; profileId: string; admissionNumber: string; - kind: 'NEW' | 'RETAKE'; + kind: "NEW" | "RETAKE"; attemptNumber: number; attendanceStatus: AttendanceStatus; attendanceRemark: string | null; @@ -129,12 +124,14 @@ export interface ExamRegistration { seafarerNumber: string | null; }; /** The candidate's online sitting, when one has been started. */ - attempt?: { id: string; status: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED' } | null; + attempt?: { + id: string; + status: "IN_PROGRESS" | "SUBMITTED" | "EXPIRED"; + } | null; } export type RegradeOutcome = - | { graded: true; resultId: string } - | { graded: false; reason: string }; + { graded: true; resultId: string } | { graded: false; reason: string }; /** One question's row on the staff grading sheet — the candidate's own * answer plus the auto-computable score, where one exists. */ @@ -161,17 +158,10 @@ export interface RecordAttendancePayload { } export type ExamIncidentType = - | 'MISCONDUCT' - | 'TECHNICAL_FAILURE' - | 'MEDICAL' - | 'ADMINISTRATIVE' - | 'OTHER'; + "MISCONDUCT" | "TECHNICAL_FAILURE" | "MEDICAL" | "ADMINISTRATIVE" | "OTHER"; export type ExamIncidentStatus = - | 'OPEN' - | 'UNDER_REVIEW' - | 'RESOLVED' - | 'DISMISSED'; + "OPEN" | "UNDER_REVIEW" | "RESOLVED" | "DISMISSED"; export interface ExamIncident { id: string; @@ -196,6 +186,6 @@ export interface CreateIncidentPayload { export interface ResolveIncidentPayload { incidentId: string; - outcome: 'RESOLVED' | 'DISMISSED'; + outcome: "RESOLVED" | "DISMISSED"; resolution: string; } diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 8653938e7..2ce63ea65 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -65,16 +65,9 @@ export interface ActionDefinition { */ export const ACTIONS: ActionDefinition[] = [ // ------------------------------------------------------------- workflow - { - id: 'claim', - tier: 'workflow', - labelKey: 'review.actions.claim', - // Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP) - // sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED. - from: ['SUBMITTED', 'ELIGIBILITY_PAID'], - permissions: ['can:claim:license-application'], - emphasis: 'light', - }, + // Claim is deliberately absent here: an officer claims from the queue + // (LicenseQueuePage), not from this detail page. That implementation is + // separate — see LicenseQueuePage/actions.tsx — and is unaffected by this. { id: 'assign', tier: 'workflow',