Add rankKey support to certification forms and exams

- Introduced rankKey field in Certification interface and payloads.
- Updated CertificationForm to handle rankKey input.
- Enhanced ScheduleExamModal to filter exams based on rank.
- Added getEligibleExams API query to fetch exams relevant to the application's rank.
- Updated related components and types to accommodate new rankKey functionality.
This commit is contained in:
Nati
2026-08-21 19:10:01 +00:00
parent 21ffca9094
commit c3977897b7
8 changed files with 85 additions and 14 deletions

View File

@@ -167,7 +167,7 @@ export function DocumentRequirementEditorDrawer({
<> <>
<Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" /> <Divider label={t('certReq.condition.title', 'Condition')} labelPosition="left" />
<ConditionBuilder <ConditionBuilder
value={(draft.conditionExpression ?? null) as ConditionValue | null} value={(draft.conditionExpression ?? { field: '', equals: '' }) as ConditionValue}
onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))} onChange={(v) => setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))}
targets={conditionTargets} targets={conditionTargets}
palette={palette} palette={palette}

View File

@@ -16,6 +16,15 @@ export function certificationColumns(
header: t('certification.columns.description'), header: t('certification.columns.description'),
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>, cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
}, },
{
header: t('certification.columns.rank', 'Rank'),
cell: ({ row }) =>
row.original.rankKey ? (
<Badge size="sm" variant="outline" color="violet">{row.original.rankKey}</Badge>
) : (
<Text fz="sm" c="dimmed"></Text>
),
},
{ {
header: t('certification.columns.status'), header: t('certification.columns.status'),
cell: ({ row }) => ( cell: ({ row }) => (

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import {Stack, Button, Modal, Text, TextInput, Textarea, Card} from '@mantine/core'; import {Stack, Button, Modal, Text, TextInput, Textarea, Select, Card} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {IconPlus} from '@tabler/icons-react'; import {IconPlus} from '@tabler/icons-react';
@@ -10,7 +10,7 @@ import {
useUpdateCertificationMutation, useUpdateCertificationMutation,
useDeleteCertificationMutation, useDeleteCertificationMutation,
} from '../../api/certification-api'; } from '../../api/certification-api';
import type { Certification } from '../../types/certification'; import { RANK_KEY_OPTIONS, type Certification } from '../../types/certification';
import { certificationColumns } from './columns'; import { certificationColumns } from './columns';
import { certificationActionsColumn } from './actions'; import { certificationActionsColumn } from './actions';
@@ -22,7 +22,7 @@ function CertificationForm({
}: { }: {
editing: Certification | null; editing: Certification | null;
isSubmitting: boolean; isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void; onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,6 +30,7 @@ function CertificationForm({
const [nameAm, setNameAm] = useState(editing?.name?.am ?? ''); const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
const [descEn, setDescEn] = useState(editing?.description?.en ?? ''); const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? ''); const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const [rankKey, setRankKey] = useState<string | null>(editing?.rankKey ?? null);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -37,7 +38,7 @@ function CertificationForm({
notify.error('Name fields are required'); notify.error('Name fields are required');
return; return;
} }
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing); onSubmit({ nameEn, nameAm, descEn, descAm, rankKey }, !!editing);
}; };
return ( return (
@@ -48,6 +49,17 @@ function CertificationForm({
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required /> <TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} /> <Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} /> <Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Select
label={t('certification.form.rankKey', 'STCW rank (for exam scheduling)')}
description={t('certification.form.rankKeyHint', 'Leave blank if this certification is not part of the examined CoC/CoP ladder.')}
placeholder={t('certification.form.rankKeyPlaceholder', 'Not rank-specific')}
data={RANK_KEY_OPTIONS as unknown as { value: string; label: string }[]}
value={rankKey}
onChange={setRankKey}
size="sm"
clearable
searchable
/>
<ModalFooter> <ModalFooter>
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button> <Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button> <Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
@@ -80,15 +92,17 @@ export function CertificationPage() {
setShowForm(false); setShowForm(false);
}; };
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => { const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string; rankKey: string | null }, isEdit: boolean) => {
const name = { en: values.nameEn, am: values.nameAm }; const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm }; const description = { en: values.descEn, am: values.descAm };
try { try {
if (isEdit && editing) { if (isEdit && editing) {
await updateCert({ id: editing.id, name, description }).unwrap(); // null clears a previously-set rank; undefined would leave it
// untouched server-side, so the two are not interchangeable here.
await updateCert({ id: editing.id, name, description, rankKey: values.rankKey }).unwrap();
notify.success(t('certification.updated')); notify.success(t('certification.updated'));
} else { } else {
await createCert({ name, description }).unwrap(); await createCert({ name, description, rankKey: values.rankKey ?? undefined }).unwrap();
notify.success(t('certification.created')); notify.success(t('certification.created'));
} }
resetForm(); resetForm();

View File

@@ -3,11 +3,30 @@ export interface LocalePair {
am: string; am: string;
} }
/**
* STCW rank an exam certification is for — the join that lets the
* schedule-exam picker offer only sittings valid for an application's rank.
* Matches `LicenseApplication.formData.certificate.rank` / `rankEngine` /
* `proficiency` on the backend. Not every certification is on the examined
* ladder, so this stays a plain optional string rather than a required enum.
*/
export const RANK_KEY_OPTIONS = [
{ value: 'OOW_DECK', label: 'Officer of the Watch (Deck)' },
{ value: 'CHIEF_MATE', label: 'Chief Mate' },
{ value: 'MASTER', label: 'Master' },
{ value: 'OOW_ENGINE', label: 'Officer of the Watch (Engine)' },
{ value: 'SECOND_ENGINEER', label: 'Second Engineer' },
{ value: 'CHIEF_ENGINEER', label: 'Chief Engineer' },
{ value: 'ABLE_SEAFARER_DECK', label: 'Able Seafarer Deck' },
{ value: 'ABLE_SEAFARER_ENGINE', label: 'Able Seafarer Engine' },
] as const;
export interface Certification { export interface Certification {
id: string; id: string;
name: LocalePair; name: LocalePair;
description: LocalePair; description: LocalePair;
isActive: boolean; isActive: boolean;
rankKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -20,6 +39,7 @@ export interface ListResponse<T> {
export interface CreateCertificationPayload { export interface CreateCertificationPayload {
name: LocalePair; name: LocalePair;
description: LocalePair; description: LocalePair;
rankKey?: string;
} }
export interface UpdateCertificationPayload { export interface UpdateCertificationPayload {
@@ -27,4 +47,6 @@ export interface UpdateCertificationPayload {
name?: LocalePair; name?: LocalePair;
description?: LocalePair; description?: LocalePair;
isActive?: boolean; isActive?: boolean;
/** Omit to leave unchanged, null to clear a previously-set rank. */
rankKey?: string | null;
} }

View File

@@ -3,10 +3,11 @@ import { Alert, Button, Modal, Select, Stack, Text, TextInput } from '@mantine/c
import { IconCalendarEvent } from '@tabler/icons-react'; import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui'; import { ModalFooter } from '@ema-platform/ui';
import { useGetExamsQuery } from '../../exam/api/exam-api'; import { useGetEligibleExamsQuery } from '@ema-platform/api';
interface Props { interface Props {
opened: boolean; opened: boolean;
applicationId: string;
applicantName: string; applicantName: string;
loading: boolean; loading: boolean;
onClose: () => void; onClose: () => void;
@@ -22,27 +23,30 @@ interface Props {
* *
* Sessions are picked from the exam calendar rather than typed, because the * Sessions are picked from the exam calendar rather than typed, because the
* candidate joins a scheduled sitting — this is an assignment, not the creation * candidate joins a scheduled sitting — this is an assignment, not the creation
* of a per-candidate appointment. * 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.
*/ */
export function ScheduleExamModal({ export function ScheduleExamModal({
opened, opened,
applicationId,
applicantName, applicantName,
loading, loading,
onClose, onClose,
onConfirm, onConfirm,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: exams, isLoading } = useGetExamsQuery(undefined, { skip: !opened }); const { data: exams, isLoading } = useGetEligibleExamsQuery(applicationId, { skip: !opened });
const [examId, setExamId] = useState<string | null>(null); const [examId, setExamId] = useState<string | null>(null);
const [admissionNumber, setAdmissionNumber] = useState(''); const [admissionNumber, setAdmissionNumber] = useState('');
const options = (exams?.items ?? []).map((exam) => ({ const options = (exams ?? []).map((exam) => ({
value: exam.id, value: exam.id,
label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date] label: [exam.title?.en ?? exam.title?.am ?? t('exam.untitled', 'Untitled exam'), exam.date]
.filter(Boolean) .filter(Boolean)
.join(' — '), .join(' — '),
})); }));
const selected = exams?.items?.find((exam) => exam.id === examId); const selected = exams?.find((exam) => exam.id === examId);
function confirm() { function confirm() {
if (!examId) return; if (!examId) return;
@@ -72,7 +76,7 @@ export function ScheduleExamModal({
<Alert color="orange" icon={<IconCalendarEvent size={16} />}> <Alert color="orange" icon={<IconCalendarEvent size={16} />}>
{t( {t(
'review.scheduleExam.noSessions', 'review.scheduleExam.noSessions',
'No exam sessions exist yet. Create one in the Exams area first.', 'No exam sessions for this rank exist yet. Create one in the Exams area first.',
)} )}
</Alert> </Alert>
) : ( ) : (

View File

@@ -1162,6 +1162,7 @@ export function LicenseReviewPage() {
<ScheduleExamModal <ScheduleExamModal
opened={scheduleExamOpen} opened={scheduleExamOpen}
applicationId={id}
applicantName={ applicantName={
app.companyName || app.companyName ||
applicantFullName || applicantFullName ||

View File

@@ -21,6 +21,7 @@ import type {
AssignableOfficer, AssignableOfficer,
DocumentDecision, DocumentDecision,
DocumentReview, DocumentReview,
EligibleExam,
ExportResult, ExportResult,
LicenseTemplate, LicenseTemplate,
Paginated, Paginated,
@@ -565,6 +566,15 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}), }),
/**
* Exam sittings valid for this application's rank — what the
* schedule-exam picker offers, instead of every exam in the system.
*/
getEligibleExams: builder.query<EligibleExam[], string>({
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
}),
/** Places a candidate who has paid the examination fee into a sitting. */ /** Places a candidate who has paid the examination fee into a sitting. */
scheduleExam: builder.mutation< scheduleExam: builder.mutation<
LicenseApplication, LicenseApplication,
@@ -963,6 +973,7 @@ export const {
useApproveDocumentsMutation, useApproveDocumentsMutation,
useFinalApproveMutation, useFinalApproveMutation,
useRejectApplicationMutation, useRejectApplicationMutation,
useGetEligibleExamsQuery,
useScheduleExamMutation, useScheduleExamMutation,
useRecordExamOutcomeMutation, useRecordExamOutcomeMutation,
useRetakeExamMutation, useRetakeExamMutation,

View File

@@ -690,3 +690,13 @@ export interface IssuedLicense {
verificationCode: string; verificationCode: string;
certificateFileKey: string | null; certificateFileKey: string | null;
} }
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;
title: { en: string; am: string };
date: string;
venue: string;
status: string;
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
}