mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: add seafarer registration feature with multi-step form
- Implemented SeafarerRegistrationPage component with five-step registration process. - Added API endpoints for seafarer registration including start, save, and submit functionalities. - Created necessary types and constants for seafarer registration. - Updated router to include new registration paths and permissions. - Integrated profile defaults to pre-fill registration fields where applicable. - Added validation and error handling for registration steps. - Enhanced document upload functionality specific to seafarer registration.
This commit is contained in:
@@ -4,6 +4,7 @@ export * from './lib/session';
|
||||
export * from './lib/features/licensing';
|
||||
export * from './lib/features/location';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/seafarer-registration';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument } from './lib/base-api/download';
|
||||
|
||||
@@ -27,7 +27,8 @@ export async function uploadDocument(params: {
|
||||
| 'INSPECTION'
|
||||
| 'LICENSE'
|
||||
| 'SEA_SERVICE_RECORD'
|
||||
| 'MEDICAL_CERTIFICATE';
|
||||
| 'MEDICAL_CERTIFICATE'
|
||||
| 'SEAFARER_REGISTRATION';
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
file: File;
|
||||
|
||||
3
libs/api/src/lib/features/seafarer-registration/index.ts
Normal file
3
libs/api/src/lib/features/seafarer-registration/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './seafarer-registration.types';
|
||||
export * from './seafarer-registration.constants';
|
||||
export * from './seafarer-registration-api';
|
||||
@@ -0,0 +1,124 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { Attachment } from '../licensing/licensing.types';
|
||||
import type {
|
||||
SaveSeafarerRegistration,
|
||||
SeafarerRegistration,
|
||||
SeafarerRegistrationStatus,
|
||||
} from './seafarer-registration.types';
|
||||
|
||||
const TAG = 'SeafarerRegistration' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
const item = (id: string) => ({ type: TAG, id }) as const;
|
||||
|
||||
export interface SeafarerRegistrationListFilter {
|
||||
status?: SeafarerRegistrationStatus;
|
||||
search?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seafarer registration — its own endpoints, not a licence application.
|
||||
* Uploads still go through `/attachments` with ownerType SEAFARER_REGISTRATION.
|
||||
*/
|
||||
export const seafarerRegistrationApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// ------------------------------------------------------------ applicant
|
||||
getMySeafarerRegistration: builder.query<{ registration: SeafarerRegistration | null }, void>({
|
||||
query: () => ({ url: '/seafarer-registrations/mine' }),
|
||||
providesTags: (r) => [LIST, ...(r?.registration ? [item(r.registration.id)] : [])],
|
||||
}),
|
||||
|
||||
startSeafarerRegistration: builder.mutation<SeafarerRegistration, void>({
|
||||
query: () => ({ url: '/seafarer-registrations', method: 'POST' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
saveSeafarerRegistration: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; body: SaveSeafarerRegistration }
|
||||
>({
|
||||
query: ({ id, body }) => ({ url: `/seafarer-registrations/${id}`, method: 'PUT', body }),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
submitSeafarerRegistration: builder.mutation<SeafarerRegistration, string>({
|
||||
query: (id) => ({ url: `/seafarer-registrations/${id}/submit`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------------- review
|
||||
listSeafarerRegistrations: builder.query<
|
||||
{ total: number; items: SeafarerRegistration[] },
|
||||
SeafarerRegistrationListFilter
|
||||
>({
|
||||
query: (params) => ({ url: '/seafarer-registration-review', params }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
getSeafarerRegistrationReview: builder.query<
|
||||
{ registration: SeafarerRegistration; attachments: Attachment[] },
|
||||
string
|
||||
>({
|
||||
query: (id) => ({ url: `/seafarer-registration-review/${id}` }),
|
||||
providesTags: (_r, _e, id) => [item(id)],
|
||||
}),
|
||||
|
||||
claimSeafarerRegistration: builder.mutation<SeafarerRegistration, string>({
|
||||
query: (id) => ({ url: `/seafarer-registration-review/${id}/claim`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
approveSeafarerRegistration: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-registration-review/${id}/approve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
rejectSeafarerRegistration: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; reason: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-registration-review/${id}/reject`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
requestSeafarerRegistrationChanges: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; remark: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-registration-review/${id}/request-changes`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useStartSeafarerRegistrationMutation,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
useSubmitSeafarerRegistrationMutation,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
useGetSeafarerRegistrationReviewQuery,
|
||||
useClaimSeafarerRegistrationMutation,
|
||||
useApproveSeafarerRegistrationMutation,
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} = seafarerRegistrationApi;
|
||||
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
SeafarerRegistrationAnswers,
|
||||
SeafarerRegistrationStatus,
|
||||
} from './seafarer-registration.types';
|
||||
|
||||
/**
|
||||
* Option lists and labels shared by the portal wizard and the backoffice
|
||||
* review screen. Values are the server's enum members.
|
||||
*/
|
||||
export const GENDER_OPTIONS = [
|
||||
{ value: 'MALE', label: 'Male' },
|
||||
{ value: 'FEMALE', label: 'Female' },
|
||||
];
|
||||
|
||||
export const MARITAL_STATUS_OPTIONS = [
|
||||
{ value: 'SINGLE', label: 'Single' },
|
||||
{ value: 'MARRIED', label: 'Married' },
|
||||
{ value: 'DIVORCED', label: 'Divorced' },
|
||||
{ value: 'WIDOWED', label: 'Widowed' },
|
||||
];
|
||||
|
||||
export const DEPARTMENT_OPTIONS = [
|
||||
{ value: 'DECK', label: 'Deck' },
|
||||
{ value: 'ENGINE', label: 'Engine' },
|
||||
{ value: 'CATERING', label: 'Catering' },
|
||||
];
|
||||
|
||||
export const HAIR_COLOR_OPTIONS = [
|
||||
{ value: 'BLACK', label: 'Black' },
|
||||
{ value: 'BROWN', label: 'Brown' },
|
||||
{ value: 'BLONDE', label: 'Blonde' },
|
||||
{ value: 'RED', label: 'Red' },
|
||||
{ value: 'GREY', label: 'Grey' },
|
||||
{ value: 'WHITE', label: 'White' },
|
||||
{ value: 'BALD', label: 'Bald' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
export const EYE_COLOR_OPTIONS = [
|
||||
{ value: 'BROWN', label: 'Brown' },
|
||||
{ value: 'BLACK', label: 'Black' },
|
||||
{ value: 'BLUE', label: 'Blue' },
|
||||
{ value: 'GREEN', label: 'Green' },
|
||||
{ value: 'HAZEL', label: 'Hazel' },
|
||||
{ value: 'GREY', label: 'Grey' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
export const BLOOD_TYPE_OPTIONS = [
|
||||
{ value: 'A_POSITIVE', label: 'A+' },
|
||||
{ value: 'A_NEGATIVE', label: 'A−' },
|
||||
{ value: 'B_POSITIVE', label: 'B+' },
|
||||
{ value: 'B_NEGATIVE', label: 'B−' },
|
||||
{ value: 'AB_POSITIVE', label: 'AB+' },
|
||||
{ value: 'AB_NEGATIVE', label: 'AB−' },
|
||||
{ value: 'O_POSITIVE', label: 'O+' },
|
||||
{ value: 'O_NEGATIVE', label: 'O−' },
|
||||
{ value: 'UNKNOWN', label: 'Unknown' },
|
||||
];
|
||||
|
||||
/** Same plausibility bounds the API enforces (PHYSICAL_BOUNDS). */
|
||||
export const PHYSICAL_BOUNDS = {
|
||||
heightCm: { min: 100, max: 250 },
|
||||
weightKg: { min: 30, max: 250 },
|
||||
} as const;
|
||||
|
||||
/** Upload slots, keyed as the API's submission check expects them. */
|
||||
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `'passport'`: required only once a passport number is declared. */
|
||||
required: boolean | 'passport';
|
||||
accept?: string;
|
||||
}[] = [
|
||||
{
|
||||
key: 'photo',
|
||||
name: 'Passport-size Photograph',
|
||||
description: 'Recent colour photograph with a plain background.',
|
||||
required: true,
|
||||
accept: 'image/jpeg,image/png',
|
||||
},
|
||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
|
||||
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
|
||||
{ key: 'graduation', name: 'Educational Certificate', required: false },
|
||||
{
|
||||
key: 'medical_certificate',
|
||||
name: 'Medical Certificate',
|
||||
description:
|
||||
'Your STCW medical fitness certificate. Must match the certificate details entered above.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'basic_training_evidence',
|
||||
name: 'Basic Training Evidence',
|
||||
description:
|
||||
'One file containing evidence of all five basic training competencies: Personal Survival Techniques (PST), Fire Prevention and Fire Fighting (FPFF), Elementary First Aid (EFA), Personal Safety and Social Responsibility (PSSR), and Security Awareness.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
RESUBMIT_REQUIRED: 'Corrections Requested',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
};
|
||||
|
||||
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** Human label for each answer — the review table and the summary both use it. */
|
||||
export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrationAnswers, string> = {
|
||||
firstName: 'First Name',
|
||||
middleName: 'Middle Name',
|
||||
lastName: 'Last Name',
|
||||
gender: 'Gender',
|
||||
dateOfBirth: 'Date of Birth',
|
||||
maritalStatus: 'Marital Status',
|
||||
nationality: 'Nationality',
|
||||
nationalIdNumber: 'National ID (Fayda) Number',
|
||||
placeOfBirth: 'Place of Birth',
|
||||
passportNumber: 'Passport Number',
|
||||
passportExpiry: 'Passport Expiry Date',
|
||||
department: 'Department',
|
||||
locationId: 'Location',
|
||||
permanentAddress: 'Permanent Address',
|
||||
currentAddress: 'Current Address',
|
||||
emergencyContactName: 'Full Name',
|
||||
emergencyContactRelationship: 'Relationship',
|
||||
emergencyContactPhone: 'Phone Number',
|
||||
hairColor: 'Hair Colour',
|
||||
eyeColor: 'Eye Colour',
|
||||
heightCm: 'Height (cm)',
|
||||
weightKg: 'Weight (kg)',
|
||||
bloodType: 'Blood Type',
|
||||
medicalCertificateNumber: 'Certificate Number',
|
||||
medicalIssuerName: 'Issuing Clinic or Practitioner',
|
||||
medicalIssueDate: 'Issue Date',
|
||||
declarationAccepted: 'Declaration',
|
||||
};
|
||||
|
||||
/**
|
||||
* The answers grouped the way the wizard asks them — the summary on the
|
||||
* portal and the review screen in the backoffice render the same sections.
|
||||
*/
|
||||
export const SEAFARER_REGISTRATION_SECTIONS: {
|
||||
key: string;
|
||||
title: string;
|
||||
fields: (keyof SeafarerRegistrationAnswers)[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'identityDetails',
|
||||
title: 'Identity Details',
|
||||
fields: ['firstName', 'middleName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
|
||||
},
|
||||
{
|
||||
key: 'identity',
|
||||
title: 'Identity',
|
||||
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
title: 'Address',
|
||||
fields: ['locationId', 'permanentAddress', 'currentAddress'],
|
||||
},
|
||||
{
|
||||
key: 'physicalCharacteristics',
|
||||
title: 'Physical Characteristics',
|
||||
fields: ['hairColor', 'eyeColor', 'heightCm', 'weightKg', 'bloodType'],
|
||||
},
|
||||
{
|
||||
key: 'medicalCertificate',
|
||||
title: 'Medical Certificate',
|
||||
fields: ['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
|
||||
},
|
||||
{
|
||||
key: 'emergencyContact',
|
||||
title: 'Emergency Contact',
|
||||
fields: ['emergencyContactName', 'emergencyContactRelationship', 'emergencyContactPhone'],
|
||||
},
|
||||
];
|
||||
|
||||
const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value: string; label: string }[]>> = {
|
||||
gender: GENDER_OPTIONS,
|
||||
maritalStatus: MARITAL_STATUS_OPTIONS,
|
||||
department: DEPARTMENT_OPTIONS,
|
||||
hairColor: HAIR_COLOR_OPTIONS,
|
||||
eyeColor: EYE_COLOR_OPTIONS,
|
||||
bloodType: BLOOD_TYPE_OPTIONS,
|
||||
};
|
||||
|
||||
/** Display value for one answer: option label for enums, "—" when blank. */
|
||||
export function displaySeafarerAnswer(
|
||||
field: keyof SeafarerRegistrationAnswers,
|
||||
value: unknown,
|
||||
): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
const options = OPTION_LABELS[field];
|
||||
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
|
||||
export type SeafarerRegistrationStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'RESUBMIT_REQUIRED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED';
|
||||
|
||||
export type Gender = 'MALE' | 'FEMALE';
|
||||
export type MaritalStatus = 'SINGLE' | 'MARRIED' | 'DIVORCED' | 'WIDOWED';
|
||||
export type HairColor = 'BLACK' | 'BROWN' | 'BLONDE' | 'RED' | 'GREY' | 'WHITE' | 'BALD' | 'OTHER';
|
||||
export type EyeColor = 'BROWN' | 'BLACK' | 'BLUE' | 'GREEN' | 'HAZEL' | 'GREY' | 'OTHER';
|
||||
export type BloodType =
|
||||
| 'A_POSITIVE' | 'A_NEGATIVE' | 'B_POSITIVE' | 'B_NEGATIVE'
|
||||
| 'AB_POSITIVE' | 'AB_NEGATIVE' | 'O_POSITIVE' | 'O_NEGATIVE' | 'UNKNOWN';
|
||||
|
||||
/** Every answer the registration collects — one typed row, no form schema. */
|
||||
export interface SeafarerRegistrationAnswers {
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
gender: Gender | null;
|
||||
dateOfBirth: string | null;
|
||||
maritalStatus: MaritalStatus | null;
|
||||
/** Full demonym as the profile stores it ("Ethiopian"). */
|
||||
nationality: string | null;
|
||||
nationalIdNumber: string | null;
|
||||
placeOfBirth: string | null;
|
||||
passportNumber: string | null;
|
||||
passportExpiry: string | null;
|
||||
department: SeafarerDepartment | null;
|
||||
locationId: string | null;
|
||||
permanentAddress: string | null;
|
||||
currentAddress: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactRelationship: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
hairColor: HairColor | null;
|
||||
eyeColor: EyeColor | null;
|
||||
heightCm: number | null;
|
||||
weightKg: number | null;
|
||||
bloodType: BloodType | null;
|
||||
medicalCertificateNumber: string | null;
|
||||
medicalIssuerName: string | null;
|
||||
medicalIssueDate: string | null;
|
||||
declarationAccepted: boolean;
|
||||
}
|
||||
|
||||
export interface SeafarerRegistration extends SeafarerRegistrationAnswers {
|
||||
id: string;
|
||||
registrationNumber: string;
|
||||
applicantUserId: string;
|
||||
profileId: string | null;
|
||||
status: SeafarerRegistrationStatus;
|
||||
submittedAt: string | null;
|
||||
assignedOfficerId: string | null;
|
||||
claimedAt: string | null;
|
||||
decidedAt: string | null;
|
||||
decidedById: string | null;
|
||||
/** What the officer asked to be fixed, or noted at approval. */
|
||||
reviewRemark: string | null;
|
||||
rejectionReason: string | null;
|
||||
/** Stamped at approval. */
|
||||
seafarerNumber: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** `null` clears a field; an absent key leaves it alone. */
|
||||
export type SaveSeafarerRegistration = Partial<SeafarerRegistrationAnswers>;
|
||||
|
||||
export interface SeafarerRegistrationIssue {
|
||||
kind: 'field' | 'document';
|
||||
target: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -14,8 +14,8 @@ const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const
|
||||
|
||||
/**
|
||||
* The seafarer's evidence shelf: sea-service records and medical
|
||||
* certificates (modules 05/06). Registration itself rides the licensing
|
||||
* endpoints — a SEAFARER_REGISTRATION application through `licensingApi`.
|
||||
* certificates (modules 05/06). Registration itself lives in
|
||||
* `seafarer-registration-api.ts`.
|
||||
*/
|
||||
export const seafarerApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: TAGS })
|
||||
|
||||
Reference in New Issue
Block a user