mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-06 13:35:05 +00:00
Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1
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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Badge, Group, Paper, SimpleGrid, Text, Title } from '@mantine/core';
|
||||
import { IconClipboardCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetExamStateForApplicationQuery, type ExamState } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* What the candidate's examination actually shows, on the officer's screen.
|
||||
*
|
||||
* The detail page used to read the application status alone, so a candidate
|
||||
* who had registered, been marked present, sat the paper and had a PASSED mark
|
||||
* published still appeared to be awaiting an exam — and the officer was still
|
||||
* offered "Schedule exam" and "Record exam result" for work already done. The
|
||||
* state here is derived server-side (ExamStateService) from the registration,
|
||||
* the attempt and the published mark, so this panel and the applicant's portal
|
||||
* cannot disagree.
|
||||
*/
|
||||
const STATE_COLOR: Record<ExamState, string> = {
|
||||
NOT_REGISTERED: 'gray',
|
||||
REGISTERED: 'cyan',
|
||||
ATTENDANCE_CONFIRMED: 'indigo',
|
||||
NOT_SITTING: 'orange',
|
||||
IN_PROGRESS: 'blue',
|
||||
UNDER_EVALUATION: 'yellow',
|
||||
PASSED: 'teal',
|
||||
FAILED: 'red',
|
||||
};
|
||||
|
||||
function Row({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm">{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamStatePanel({ applicationId }: { applicationId: string }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
// Polled: attendance is recorded by an invigilator, and a paper is marked
|
||||
// and published, while the officer has this page open.
|
||||
const { data, isLoading } = useGetExamStateForApplicationQuery(applicationId, {
|
||||
pollingInterval: 30_000,
|
||||
refetchOnFocus: true,
|
||||
});
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
|
||||
const settled = data.outcome !== null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<IconClipboardCheck size={17} />
|
||||
<Title order={5}>{t('review.exam.title', 'Examination')}</Title>
|
||||
</Group>
|
||||
<Badge color={STATE_COLOR[data.state]} variant="light">
|
||||
{t(`review.exam.state.${data.state}`, data.state)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
<Row
|
||||
label={t('review.exam.session', 'Session')}
|
||||
value={data.examTitle?.[locale] ?? data.examTitle?.en}
|
||||
/>
|
||||
<Row label={t('review.exam.date', 'Exam date')} value={data.examDate} />
|
||||
<Row
|
||||
label={t('review.exam.admission', 'Admission number')}
|
||||
value={data.admissionNumber}
|
||||
/>
|
||||
{settled && (
|
||||
<>
|
||||
<Row
|
||||
label={t('review.exam.score', 'Score')}
|
||||
value={data.score === null ? null : String(data.score)}
|
||||
/>
|
||||
<Row
|
||||
label={t('review.exam.outcome', 'Outcome')}
|
||||
value={t(`review.exam.state.${data.outcome}`, data.outcome ?? '')}
|
||||
/>
|
||||
{/* Says where the mark came from, so nobody re-enters one the
|
||||
engine already produced. */}
|
||||
<Row
|
||||
label={t('review.exam.source', 'Result source')}
|
||||
value={
|
||||
data.autoGraded
|
||||
? t('review.exam.sourceEngine', 'Exam engine')
|
||||
: t('review.exam.sourceExaminer', 'Examiner')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,6 @@ export type ActionId =
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'schedule-exam'
|
||||
| 'record-exam-outcome'
|
||||
| 'confirm-payment'
|
||||
| 'schedule-issuance'
|
||||
| 'issue-certificate'
|
||||
@@ -277,31 +275,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',
|
||||
@@ -392,7 +371,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.
|
||||
*/
|
||||
|
||||
@@ -55,8 +55,6 @@ import {
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
@@ -97,8 +95,8 @@ import { ActivityRail } from "../../components/ActivityRail";
|
||||
import { DocumentsTab } from "../../components/DocumentsTab";
|
||||
import { FormDetailsTab } from "../../components/FormDetailsTab";
|
||||
import { ApplicantCard } from "../../components/ApplicantCard";
|
||||
import { ExamStatePanel } from "../../components/ExamStatePanel";
|
||||
import { useGetLocationsQuery } from "../../../location/api/location-api";
|
||||
import { ScheduleExamModal } from "../../components/ScheduleExamModal";
|
||||
import { computeSla } from "../../sla";
|
||||
import { reviewStaffColumns } from "./columns";
|
||||
import {
|
||||
@@ -164,6 +162,15 @@ function buildChecklist(
|
||||
* Decision Bar pinned to the bottom, so an officer can act from any scroll
|
||||
* position instead of scrolling back to a column of buttons.
|
||||
*/
|
||||
/** The examination leg — the only statuses the exam panel has anything to say about. */
|
||||
const EXAM_LEG_STATUSES: string[] = [
|
||||
"EXAM_PAYMENT_PENDING",
|
||||
"EXAM_PAID",
|
||||
"EXAM_SCHEDULED",
|
||||
"EXAM_PASSED",
|
||||
"EXAM_FAILED",
|
||||
];
|
||||
|
||||
export function LicenseReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -230,9 +237,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();
|
||||
@@ -273,9 +277,6 @@ export function LicenseReviewPage() {
|
||||
"MORNING",
|
||||
);
|
||||
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<
|
||||
@@ -600,16 +601,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({
|
||||
@@ -1064,6 +1055,11 @@ export function LicenseReviewPage() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Examined certificates only — null for everything else. */}
|
||||
{EXAM_LEG_STATUSES.includes(app.status) && (
|
||||
<ExamStatePanel applicationId={id} />
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t("review.statusTimeline", "Progress")}
|
||||
@@ -1393,101 +1389,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}
|
||||
|
||||
@@ -278,6 +278,9 @@ export const am: Translations = {
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
next: "ቀጣይ",
|
||||
back: "ተመለስ",
|
||||
review: "ግምገማ",
|
||||
reviewHint: "ከዚህ በታች ያለውን ዝርዝር ያረጋግጡ፣ ከዚያ ፈተናውን ይፍጠሩ። ለማስተካከል ተመለስ ይጠቀሙ።",
|
||||
durationSummary: "{{days}}ቀን {{hours}}ሰ {{minutes}}ደ",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
@@ -721,6 +724,27 @@ export const am: Translations = {
|
||||
allExams: "ሁሉም ፈተናዎች",
|
||||
},
|
||||
review: {
|
||||
exam: {
|
||||
title: "ፈተና",
|
||||
session: "ክፍለ ጊዜ",
|
||||
date: "የፈተና ቀን",
|
||||
admission: "የመግቢያ ቁጥር",
|
||||
score: "ውጤት",
|
||||
outcome: "ውሳኔ",
|
||||
source: "የውጤት ምንጭ",
|
||||
sourceEngine: "የፈተና ሞተር",
|
||||
sourceExaminer: "ፈታኝ",
|
||||
state: {
|
||||
NOT_REGISTERED: "አልተመዘገበም",
|
||||
REGISTERED: "ተመዝግቧል — መገኘት በመጠባበቅ ላይ",
|
||||
ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል",
|
||||
NOT_SITTING: "አይፈተንም",
|
||||
IN_PROGRESS: "ፈተና በመካሄድ ላይ",
|
||||
UNDER_EVALUATION: "ተጠናቋል — በግምገማ ላይ",
|
||||
PASSED: "አልፏል",
|
||||
FAILED: "አላለፈም",
|
||||
},
|
||||
},
|
||||
column: "ግምገማ",
|
||||
MARKED: "ተስተካክሏል",
|
||||
MODERATED: "ተመርምሯል",
|
||||
@@ -1073,8 +1097,6 @@ export const am: Translations = {
|
||||
finalApprove: "አጽድቅ እና ስጥ",
|
||||
requestAdjustment: "ማስተካከያ ጠይቅ",
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
recordExamOutcome: "የፈተና ውጤት መዝግብ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||
@@ -1127,7 +1149,6 @@ export const am: Translations = {
|
||||
escalate: "ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።",
|
||||
"assign-reviewer": "ማመልከቻ {{number}} ለተመረጠው ሹም ሰጥቶ ግምገማውን ያስጀምራል።",
|
||||
"confirm-payment": "ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።",
|
||||
"schedule-exam": "ለማመልከቻ {{number}} {{applicant}}ን ለፈተና ክፍለ ጊዜ ይመድባል።",
|
||||
},
|
||||
notifications: {
|
||||
fallback: "ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።",
|
||||
|
||||
@@ -278,6 +278,9 @@ export const en = {
|
||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
review: 'Review',
|
||||
reviewHint: 'Check the details below, then create the exam. Use Back to change anything.',
|
||||
durationSummary: '{{days}}d {{hours}}h {{minutes}}m',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -722,6 +725,27 @@ export const en = {
|
||||
allExams: 'All Exams',
|
||||
},
|
||||
review: {
|
||||
exam: {
|
||||
title: 'Examination',
|
||||
session: 'Session',
|
||||
date: 'Exam date',
|
||||
admission: 'Admission number',
|
||||
score: 'Score',
|
||||
outcome: 'Outcome',
|
||||
source: 'Result source',
|
||||
sourceEngine: 'Exam engine',
|
||||
sourceExaminer: 'Examiner',
|
||||
state: {
|
||||
NOT_REGISTERED: 'Not registered',
|
||||
REGISTERED: 'Registered — awaiting attendance',
|
||||
ATTENDANCE_CONFIRMED: 'Attendance confirmed',
|
||||
NOT_SITTING: 'Not sitting',
|
||||
IN_PROGRESS: 'Exam in progress',
|
||||
UNDER_EVALUATION: 'Completed — under evaluation',
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
},
|
||||
},
|
||||
column: 'Review',
|
||||
MARKED: 'Marked',
|
||||
MODERATED: 'Moderated',
|
||||
@@ -1083,8 +1107,6 @@ export const en = {
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
recordExamOutcome: 'Record exam outcome',
|
||||
confirmPayment: 'Confirm payment',
|
||||
scheduleIssuance: 'Schedule pickup',
|
||||
issueCertificate: 'Issue certificate',
|
||||
@@ -1137,7 +1159,6 @@ export const en = {
|
||||
'assign-reviewer':
|
||||
'Hands application {{number}} to the chosen officer and starts the review.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
'schedule-exam': 'Assigns {{applicant}} to an exam session for application {{number}}.',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
|
||||
Reference in New Issue
Block a user