Merge branch 'estif-branch-1' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-09-01 09:44:39 +03:00
27 changed files with 867 additions and 389 deletions

View File

@@ -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>

View File

@@ -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',
);
});
});

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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.
*/

View File

@@ -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}

View File

@@ -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}}።",

View File

@@ -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}}.',