mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
fix(exam): step the exam form, derive portal status, drop queue overrides
Four screens were telling the applicant things that were not true, and two back-office actions were writing state nobody should be able to write by hand. The exam form is a stepper. Basic Info and Settings each answer only for their own fields, and a third step reviews the whole thing before anything is sent — "Create Exam" exists on that step alone, so there is no path from an earlier one into the API. Clicking it from Basic Info used to report the Settings fields as missing: a correct error the user could not act on, since that step had not been shown yet. The rules move to ./validation, which is what the per-step check and the final whole-payload check both read, so the two cannot drift apart. Back keeps everything entered — state lives in the component. The portal derives the examination stage from the records rather than from the application status alone. Registering, attendance and the sitting itself all happen on the registration and the attempt, and the portal read none of them — which is how an application still said "Awaiting Exam Date" after the back office had already evaluated the paper. examStageFor() names what the records add up to: eligible to register, registered, attendance confirmed, sitting, under evaluation, passed, failed. Both the applications list and the certificates page use it, and both poll the registrations they read it from, because attendance is recorded by an invigilator while the candidate is watching the screen. EXAM_PAID is no longer a waiting state. Nobody assigns a date: the fee having cleared is exactly what makes the candidate eligible to pick a published sitting, so that row now offers Register rather than a disabled button. Schedule Exam and Record Exam Outcome are gone from the COC queue — actions, modals, RTK endpoints and strings. Their server endpoints are gone too, so this is not a hidden button over a live route.
This commit is contained in:
@@ -11,8 +11,9 @@ import {
|
||||
Card,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
Stepper,
|
||||
SimpleGrid,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconClipboardList,
|
||||
IconChecklist,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth";
|
||||
@@ -32,10 +34,28 @@ import {
|
||||
useDeleteExamMutation,
|
||||
} from "../../api/exam-api";
|
||||
import type { Exam } from "../../types/exam";
|
||||
import {
|
||||
LAST_STEP,
|
||||
stepOfError,
|
||||
validateAll,
|
||||
validateStep,
|
||||
type ExamFormValues,
|
||||
} from "./validation";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
import { ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm">{value || "—"}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
certOptions,
|
||||
@@ -77,45 +97,48 @@ function ExamForm({
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
/**
|
||||
* Split per tab so "Next" can check just the tab in front of the user.
|
||||
* Submitting from Basic Info used to complain about Settings fields the
|
||||
* user had not been shown yet — the error was correct and unactionable at
|
||||
* the same time. Each returns the message key for what is missing, or null.
|
||||
* Validation is per step, so "Next" only ever complains about what is in
|
||||
* front of the user. Submitting from Basic Info used to report the Settings
|
||||
* fields as missing — correct and unactionable at the same time, since that
|
||||
* step had not been shown yet. The rules live in ./validation so the
|
||||
* per-step check and the final one cannot drift apart.
|
||||
*/
|
||||
const validateBasic = (): string | null => {
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
return "exam.form.fillRequiredBasic";
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
return "exam.form.directionBothLanguages";
|
||||
}
|
||||
return null;
|
||||
const values: ExamFormValues = {
|
||||
certificationId,
|
||||
titleEn,
|
||||
titleAm,
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
venue,
|
||||
type,
|
||||
form,
|
||||
adminMethod,
|
||||
evalMethod,
|
||||
cuttingPoint,
|
||||
};
|
||||
|
||||
const validateSettings = (): string | null =>
|
||||
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
|
||||
? "exam.form.fillRequiredSettings"
|
||||
: null;
|
||||
|
||||
const goNext = () => {
|
||||
const error = validateBasic();
|
||||
const error = validateStep(step, values);
|
||||
if (error) {
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
setActiveTab("settings");
|
||||
setStep((current) => current + 1);
|
||||
};
|
||||
|
||||
/** No validation going backwards — state lives in this component, so
|
||||
* nothing entered is lost either way. */
|
||||
const goBack = () => setStep((current) => Math.max(0, current - 1));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Still checks both: the tabs are clickable, so a user can reach Settings
|
||||
// without going through Next.
|
||||
const error = validateBasic() ?? validateSettings();
|
||||
const error = validateAll(values);
|
||||
if (error) {
|
||||
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
|
||||
setStep(stepOfError(error));
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
@@ -146,20 +169,23 @@ function ExamForm({
|
||||
return (
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="settings"
|
||||
leftSection={<IconClipboardList size={15} />}
|
||||
>
|
||||
{t("exam.form.settings")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
{/* Stepper, not Tabs: the form is a sequence with a submit at the end,
|
||||
and the header has to say which step of how many rather than
|
||||
offering all of them at once. `allowStepSelect` on visited steps
|
||||
only — jumping ahead is what let the old form be submitted from
|
||||
Basic Info. */}
|
||||
<Stepper
|
||||
active={step}
|
||||
onStepClick={setStep}
|
||||
size="sm"
|
||||
radius="md"
|
||||
mb="md"
|
||||
>
|
||||
<Stepper.Step
|
||||
label={t("exam.form.basicInfo")}
|
||||
icon={<IconInfoCircle size={15} />}
|
||||
allowStepSelect={step > 0}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("exam.form.certification")}
|
||||
@@ -249,9 +275,13 @@ function ExamForm({
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Stepper.Step>
|
||||
|
||||
<Tabs.Panel value="settings">
|
||||
<Stepper.Step
|
||||
label={t("exam.form.settings")}
|
||||
icon={<IconClipboardList size={15} />}
|
||||
allowStepSelect={step > 1}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
@@ -353,32 +383,89 @@ function ExamForm({
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Read-only by design: the last step is for checking what is about
|
||||
to be created, not a third place to edit it. Back returns to the
|
||||
step that owns any field that looks wrong. */}
|
||||
<Stepper.Step
|
||||
label={t("exam.form.review")}
|
||||
icon={<IconChecklist size={15} />}
|
||||
allowStepSelect={false}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.form.reviewHint")}
|
||||
</Text>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ReviewRow
|
||||
label={t("exam.form.certification")}
|
||||
value={
|
||||
certOptions.find((c) => c.value === certificationId)?.label
|
||||
}
|
||||
/>
|
||||
<ReviewRow label={t("exam.form.titleEn")} value={titleEn} />
|
||||
<ReviewRow label={t("exam.form.titleAm")} value={titleAm} />
|
||||
<ReviewRow label={t("exam.form.examDate")} value={date} />
|
||||
<ReviewRow label={t("exam.form.venue")} value={venue} />
|
||||
<ReviewRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
value={t("exam.form.durationSummary", {
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
})}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.columns.type")}
|
||||
value={type ? t(`exam.type.${type}`) : undefined}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.columns.form")}
|
||||
value={form ? t(`exam.formType.${form}`) : undefined}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={adminMethod}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.detail.evaluation")}
|
||||
value={evalMethod}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.detail.selection")}
|
||||
value={selMethod}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
value={String(cuttingPoint)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
|
||||
<ModalFooter mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
{activeTab === "basic" ? (
|
||||
/* Not type="submit": Basic Info is not the last step, so the
|
||||
primary action advances rather than saves. */
|
||||
{step > 0 && (
|
||||
<Button variant="default" size="sm" onClick={goBack}>
|
||||
{t("exam.form.back")}
|
||||
</Button>
|
||||
)}
|
||||
{step < LAST_STEP ? (
|
||||
/* Deliberately not type="submit": only the last step submits, so
|
||||
there is no path from an earlier one into the API. */
|
||||
<Button size="sm" onClick={goNext}>
|
||||
{t("exam.form.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("basic")}
|
||||
>
|
||||
{t("exam.form.back")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LAST_STEP,
|
||||
stepOfError,
|
||||
validateAll,
|
||||
validateBasic,
|
||||
validateSettings,
|
||||
validateStep,
|
||||
type ExamFormValues,
|
||||
} from './validation';
|
||||
|
||||
const complete: ExamFormValues = {
|
||||
certificationId: 'cert-1',
|
||||
titleEn: 'Master Mariner',
|
||||
titleAm: 'ካፒቴን',
|
||||
directionEn: '',
|
||||
directionAm: '',
|
||||
date: '2026-09-10',
|
||||
venue: 'Addis Ababa',
|
||||
type: 'WRITTEN',
|
||||
form: 'CHOICE',
|
||||
adminMethod: 'ONLINE',
|
||||
evalMethod: 'SUM',
|
||||
cuttingPoint: 50,
|
||||
};
|
||||
|
||||
const basicOnly: ExamFormValues = {
|
||||
...complete,
|
||||
type: null,
|
||||
form: null,
|
||||
adminMethod: null,
|
||||
evalMethod: null,
|
||||
cuttingPoint: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicking "Create Exam" from Basic Info used to report the Settings fields as
|
||||
* missing — a correct error the user could not act on, since that step had not
|
||||
* been shown yet. Each step now answers only for its own fields.
|
||||
*/
|
||||
describe('exam form step validation', () => {
|
||||
it('keeps an incomplete Basic Info on its own step', () => {
|
||||
const error = validateStep(0, { ...basicOnly, venue: '' });
|
||||
|
||||
expect(error).toBe('exam.form.fillRequiredBasic');
|
||||
expect(stepOfError(error!)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not fault Basic Info for Settings fields that are still empty', () => {
|
||||
expect(validateStep(0, basicOnly)).toBeNull();
|
||||
});
|
||||
|
||||
it('wants a direction in both languages or in neither', () => {
|
||||
expect(validateBasic({ ...complete, directionEn: 'Answer all' })).toBe(
|
||||
'exam.form.directionBothLanguages',
|
||||
);
|
||||
expect(validateBasic({ ...complete, directionEn: 'Answer all', directionAm: 'ሁሉንም' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an incomplete Settings on its own step', () => {
|
||||
const error = validateStep(1, basicOnly);
|
||||
|
||||
expect(error).toBe('exam.form.fillRequiredSettings');
|
||||
expect(stepOfError(error!)).toBe(1);
|
||||
});
|
||||
|
||||
it('lets a complete Settings through to the review step', () => {
|
||||
expect(validateStep(1, complete)).toBeNull();
|
||||
});
|
||||
|
||||
it('asks nothing of the review step itself — it has no fields', () => {
|
||||
expect(validateStep(LAST_STEP, basicOnly)).toBeNull();
|
||||
});
|
||||
|
||||
it('checks the whole payload on submit, not just the last step', () => {
|
||||
// Reachable: the stepper header can be clicked back to a visited step, so
|
||||
// arriving at Review is no proof everything is still filled in.
|
||||
expect(validateAll(basicOnly)).toBe('exam.form.fillRequiredSettings');
|
||||
expect(validateAll({ ...complete, titleAm: '' })).toBe('exam.form.fillRequiredBasic');
|
||||
expect(validateAll(complete)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a zero cutting point as unset — a paper nobody can fail', () => {
|
||||
expect(validateSettings({ ...complete, cuttingPoint: 0 })).toBe(
|
||||
'exam.form.fillRequiredSettings',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Which step owns which required fields.
|
||||
*
|
||||
* Extracted from the form so the rule "Next checks only the step in front of
|
||||
* the user" is testable without rendering a modal — and so the final submit
|
||||
* and the per-step check can never drift apart, since both read this.
|
||||
*/
|
||||
export interface ExamFormValues {
|
||||
certificationId: string | null;
|
||||
titleEn: string;
|
||||
titleAm: string;
|
||||
directionEn: string;
|
||||
directionAm: string;
|
||||
date: string;
|
||||
venue: string;
|
||||
type: string | null;
|
||||
form: string | null;
|
||||
adminMethod: string | null;
|
||||
evalMethod: string | null;
|
||||
cuttingPoint: number;
|
||||
}
|
||||
|
||||
/** Basic Info, Settings, Review — only the last one submits. */
|
||||
export const LAST_STEP = 2;
|
||||
|
||||
export function validateBasic(v: ExamFormValues): string | null {
|
||||
if (!v.certificationId || !v.titleEn || !v.titleAm || !v.date || !v.venue) {
|
||||
return 'exam.form.fillRequiredBasic';
|
||||
}
|
||||
// Either both languages or neither: a direction in one language only reads
|
||||
// as missing to half the candidates.
|
||||
if ((v.directionEn || v.directionAm) && !(v.directionEn && v.directionAm)) {
|
||||
return 'exam.form.directionBothLanguages';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateSettings(v: ExamFormValues): string | null {
|
||||
return !v.type || !v.form || !v.adminMethod || !v.evalMethod || !v.cuttingPoint
|
||||
? 'exam.form.fillRequiredSettings'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The step in front of the user, and nothing else. Review has no fields. */
|
||||
export function validateStep(step: number, v: ExamFormValues): string | null {
|
||||
if (step === 0) return validateBasic(v);
|
||||
if (step === 1) return validateSettings(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole payload, as the final check before submitting.
|
||||
*
|
||||
* Not redundant with the per-step checks: the stepper header can be clicked
|
||||
* back to a visited step, so arriving at Review is no proof the user walked
|
||||
* here in order with everything still filled in.
|
||||
*/
|
||||
export function validateAll(v: ExamFormValues): string | null {
|
||||
return validateBasic(v) ?? validateSettings(v);
|
||||
}
|
||||
|
||||
/** Where a failure has to land for the user to be able to fix it. */
|
||||
export function stepOfError(error: string): number {
|
||||
return error === 'exam.form.fillRequiredSettings' ? 1 : 0;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import { useGetEligibleExamsQuery } from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
applicationId: string;
|
||||
applicantName: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (payload: { examId: string; examDate?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a candidate who has paid the examination fee into an existing sitting.
|
||||
*
|
||||
* Sessions are picked from the exam calendar rather than typed, because the
|
||||
* candidate joins a scheduled sitting — this is an assignment, not the creation
|
||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||
* into an OOW Deck sitting by accident.
|
||||
*
|
||||
* Scheduling makes the sitting available; it does not register the candidate.
|
||||
* That is their own act, from the portal's Register button.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
applicationId,
|
||||
applicantName,
|
||||
loading,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
|
||||
const [examId, setExamId] = useState<string | null>(null);
|
||||
|
||||
const options = (exams ?? []).map((exam) => ({
|
||||
value: exam.id,
|
||||
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
}));
|
||||
const selected = exams?.find((exam) => exam.id === examId);
|
||||
|
||||
function confirm() {
|
||||
if (!examId) return;
|
||||
onConfirm({
|
||||
examId,
|
||||
examDate: selected?.date ? String(selected.date) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('review.actions.scheduleExam', 'Schedule exam')}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.scheduleExam.intro', {
|
||||
defaultValue:
|
||||
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
|
||||
applicant: applicantName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{!isLoading && options.length === 0 ? (
|
||||
<Alert color="orange" icon={<IconCalendarEvent size={16} />}>
|
||||
{t(
|
||||
'review.scheduleExam.noSessions',
|
||||
'No exam sessions for this rank exist yet. Create one in the Exams area first.',
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Select
|
||||
label={t('review.scheduleExam.session', 'Exam session')}
|
||||
placeholder={t('review.scheduleExam.pick', 'Choose a sitting')}
|
||||
data={options}
|
||||
value={examId}
|
||||
onChange={setExamId}
|
||||
disabled={isLoading}
|
||||
searchable
|
||||
withAsterisk
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={loading} disabled={!examId} onClick={confirm}>
|
||||
{t('review.scheduleExam.confirm', 'Schedule')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,6 @@ export type ActionId =
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'record-exam-outcome'
|
||||
| 'confirm-payment'
|
||||
| 'schedule-issuance'
|
||||
| 'issue-certificate'
|
||||
@@ -264,31 +262,12 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
requiresReason: true,
|
||||
irreversible: true,
|
||||
},
|
||||
{
|
||||
id: 'schedule-exam',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.scheduleExam',
|
||||
// Only after the examination fee clears — scheduling an unpaid candidate
|
||||
// is what the EXAM_PAID gate exists to prevent.
|
||||
from: ['EXAM_PAID'],
|
||||
// Matches the controller's guard on `:id/exam-scheduled`
|
||||
// (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't
|
||||
// correspond to any real permission constant, so this button could never
|
||||
// actually be granted to anyone.
|
||||
permissions: ['can:manage:exams'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'record-exam-outcome',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.recordExamOutcome',
|
||||
// Only once the candidate has actually sat the exam.
|
||||
from: ['EXAM_SCHEDULED'],
|
||||
permissions: ['can:publish:exam-result'],
|
||||
emphasis: 'filled',
|
||||
color: 'cyan',
|
||||
},
|
||||
// No `schedule-exam` or `record-exam-outcome`. CoC sittings are published as
|
||||
// master schedules and the candidate registers for one themselves, so an
|
||||
// officer action that seated a named applicant registered people who had
|
||||
// never asked. An outcome is what the exam engine computed and a supervisor
|
||||
// approved; a queue action taking pass/fail and a score let the back office
|
||||
// manufacture one with no paper behind it. Both endpoints are gone too.
|
||||
{
|
||||
id: 'confirm-payment',
|
||||
tier: 'primary',
|
||||
@@ -379,7 +358,7 @@ export interface ResolveContext {
|
||||
/**
|
||||
* Action ids that are workflow events, so `availableEvents` decides them.
|
||||
*
|
||||
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
|
||||
* The rest (`schedule-inspection`, the secondary tools) are
|
||||
* screens and side effects rather than transitions, and the server has no
|
||||
* opinion on them — those keep using their own `from` list.
|
||||
*/
|
||||
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -96,7 +94,6 @@ import { DocumentsTab } from "../../components/DocumentsTab";
|
||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||
import { useGetLocationsQuery } from "../../../location/api/location-api";
|
||||
import { ScheduleExamModal } from "../../components/ScheduleExamModal";
|
||||
import { computeSla } from "../../sla";
|
||||
import { reviewStaffColumns } from "./columns";
|
||||
import {
|
||||
@@ -227,9 +224,6 @@ export function LicenseReviewPage() {
|
||||
null,
|
||||
);
|
||||
const [certificatePreview, setCertificatePreview] = useState<string | null>(null);
|
||||
const [scheduleExam, { isLoading: schedulingExam }] =
|
||||
useScheduleExamMutation();
|
||||
const [recordExamOutcome] = useRecordExamOutcomeMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
@@ -264,9 +258,6 @@ export function LicenseReviewPage() {
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
const [examScore, setExamScore] = useState<number | undefined>();
|
||||
const [findings, setFindings] = useState("");
|
||||
const [findingsUploadBusy, setFindingsUploadBusy] = useState(false);
|
||||
const [findingsPreview, setFindingsPreview] = useState<
|
||||
@@ -570,16 +561,6 @@ export function LicenseReviewPage() {
|
||||
case "record-inspection":
|
||||
setResultOpen(true);
|
||||
return;
|
||||
// Needs a session picked before anything is sent, so it opens its own
|
||||
// modal rather than going through the generic confirm step.
|
||||
case "schedule-exam":
|
||||
setScheduleExamOpen(true);
|
||||
return;
|
||||
// Pass/fail plus an optional score, same reasoning as schedule-exam:
|
||||
// needs its own inputs before anything is sent.
|
||||
case "record-exam-outcome":
|
||||
setExamOutcomeOpen(true);
|
||||
return;
|
||||
case "copy-link":
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
notifications.show({
|
||||
@@ -1331,101 +1312,10 @@ export function LicenseReviewPage() {
|
||||
onConfirm={submitDecision}
|
||||
/>
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicationId={id}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
t("review.theApplicant", "the applicant")
|
||||
}
|
||||
loading={schedulingExam}
|
||||
onClose={() => setScheduleExamOpen(false)}
|
||||
onConfirm={async (payload) => {
|
||||
try {
|
||||
await scheduleExam({ id, ...payload }).unwrap();
|
||||
notifications.show({
|
||||
color: "teal",
|
||||
title: t("review.done.scheduleExam", "Exam scheduled"),
|
||||
message: "",
|
||||
});
|
||||
setScheduleExamOpen(false);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: t("review.actionFailed", "Action failed"),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={examOutcomeOpen}
|
||||
onClose={() => setExamOutcomeOpen(false)}
|
||||
title={t("review.actions.recordExamOutcome", "Record exam outcome")}
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"review.examOutcome.intro",
|
||||
"Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.",
|
||||
)}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label={t("review.examOutcome.score", "Score (optional)")}
|
||||
value={examScore}
|
||||
onChange={(v) => setExamScore(typeof v === "number" ? v : undefined)}
|
||||
min={0}
|
||||
/>
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="lg"
|
||||
aria-label={t("review.passed", "Passed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await recordExamOutcome({
|
||||
id,
|
||||
passed: true,
|
||||
score: examScore,
|
||||
}).unwrap();
|
||||
setExamOutcomeOpen(false);
|
||||
setExamScore(undefined);
|
||||
},
|
||||
t("review.done.examPassed", "Exam result recorded — passed"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
aria-label={t("review.failed", "Failed")}
|
||||
onClick={() =>
|
||||
run(
|
||||
async () => {
|
||||
await recordExamOutcome({
|
||||
id,
|
||||
passed: false,
|
||||
score: examScore,
|
||||
}).unwrap();
|
||||
setExamOutcomeOpen(false);
|
||||
setExamScore(undefined);
|
||||
},
|
||||
t("review.done.examFailed", "Exam result recorded — not passed"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{/* No exam scheduling or outcome modal. Candidates register themselves
|
||||
for a published sitting, and the outcome is whatever the exam engine
|
||||
computed and a supervisor approved — the queue reads results, it does
|
||||
not write them. */}
|
||||
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
|
||||
Reference in New Issue
Block a user