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

This commit is contained in:
Estifo77
2026-08-20 10:34:06 +03:00
24 changed files with 2155 additions and 450 deletions

View File

@@ -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, downloadAuthedFile } from './lib/base-api/download';

View File

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

View File

@@ -0,0 +1,3 @@
export * from './seafarer-registration.types';
export * from './seafarer-registration.constants';
export * from './seafarer-registration-api';

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,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 })