mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
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:
@@ -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}
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1162,6 +1162,7 @@ export function LicenseReviewPage() {
|
|||||||
|
|
||||||
<ScheduleExamModal
|
<ScheduleExamModal
|
||||||
opened={scheduleExamOpen}
|
opened={scheduleExamOpen}
|
||||||
|
applicationId={id}
|
||||||
applicantName={
|
applicantName={
|
||||||
app.companyName ||
|
app.companyName ||
|
||||||
applicantFullName ||
|
applicantFullName ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user