mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain
# Conflicts: # apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx # apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx # libs/api/src/lib/features/licensing/licensing.helpers.ts # libs/auth/src/lib/components/AuthBootstrap.tsx
This commit is contained in:
@@ -2,7 +2,10 @@ export * from './lib/base-api';
|
||||
export * from './lib/query-and-mutation';
|
||||
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/seafarer-document';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument } from './lib/base-api/download';
|
||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||
|
||||
@@ -42,8 +42,13 @@ export const baseQueryWithReauth: BaseQueryFn<
|
||||
try {
|
||||
await _onTokenExpired();
|
||||
result = await baseQuery(args, api, extraOptions);
|
||||
} catch {
|
||||
_onAuthFailure?.();
|
||||
} catch (err) {
|
||||
// Only a rejected refresh token ends the session. A network blip or a
|
||||
// 5xx leaves the original 401 for the screen to report, rather than
|
||||
// throwing the user out of a session that is still valid.
|
||||
if ((err as { sessionExpired?: boolean })?.sessionExpired) {
|
||||
_onAuthFailure?.();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_onAuthFailure?.();
|
||||
|
||||
@@ -41,3 +41,57 @@ export async function openAuthedDocument(
|
||||
// Revoking immediately would race the new tab's load.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an authenticated endpoint straight to a file.
|
||||
*
|
||||
* Same reason as `openAuthedDocument` for bypassing RTK Query — `fetchBaseQuery`
|
||||
* would parse a CSV body as JSON — but a spreadsheet is something you save, not
|
||||
* something the browser can display, so this always takes the anchor path.
|
||||
*
|
||||
* The server names the file via `Content-Disposition`, and the API's CORS
|
||||
* config exposes that header along with `X-Total-Rows` and `X-Truncated`; those
|
||||
* two are returned so a caller can say when an export was cut short instead of
|
||||
* handing over a silently partial file.
|
||||
*/
|
||||
export async function downloadAuthedFile(
|
||||
path: string,
|
||||
fallbackName: string,
|
||||
): Promise<{ rowCount: number | null; truncated: boolean }> {
|
||||
const token = resolveTokenFromStorage();
|
||||
const response = await fetch(`${BASE_API_URL}${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body?.message ?? message;
|
||||
} catch {
|
||||
/* non-JSON error body — the status is all we have */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filenameFrom(response.headers) ?? fallbackName;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
|
||||
const rows = response.headers.get('X-Total-Rows');
|
||||
return {
|
||||
rowCount: rows === null ? null : Number(rows),
|
||||
truncated: response.headers.get('X-Truncated') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/** `attachment; filename="vessel-register-2026-08-18.csv"` → the file name. */
|
||||
function filenameFrom(headers: Headers): string | null {
|
||||
const disposition = headers.get('Content-Disposition');
|
||||
if (!disposition) return null;
|
||||
const match = /filename="?([^";]+)"?/.exec(disposition);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const tagTypes = ["ProfessionApi"];
|
||||
export const tagTypes = ["ProfessionApi", "NumberFormatApi"];
|
||||
|
||||
@@ -398,8 +398,11 @@ export const licensingApi = baseApi
|
||||
* claim or a decision refreshes the badges along with the list and the
|
||||
* numbers can never drift from what the grid is showing.
|
||||
*/
|
||||
getQueueCounts: builder.query<QueueCounts, void>({
|
||||
query: () => ({ url: '/license-application-review/counts' }),
|
||||
getQueueCounts: builder.query<QueueCounts, string | void>({
|
||||
query: (licenseTypeKey) => ({
|
||||
url: '/license-application-review/counts',
|
||||
params: licenseTypeKey ? { licenseTypeKey } : undefined,
|
||||
}),
|
||||
providesTags: () => [listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
@@ -721,6 +724,28 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
scheduleIssuance: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; scheduledDate: string }
|
||||
>({
|
||||
query: ({ id, scheduledDate }) => ({
|
||||
url: `/license-application-review/${id}/schedule-issuance`,
|
||||
method: 'POST',
|
||||
body: { scheduledDate },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
issueCertificate: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({
|
||||
url: `/license-application-review/${id}/issue-certificate`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
@@ -841,6 +866,8 @@ export const {
|
||||
useScheduleExamMutation,
|
||||
useRequestExamPaymentMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
useRecordInspectionResultMutation,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { resolveTokenFromStorage } from "../../session";
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
LicenseStatus,
|
||||
ValidationIssue,
|
||||
} from "./licensing.types";
|
||||
} from './licensing.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3001/api";
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
@@ -21,12 +22,13 @@ const BASE_API_URL =
|
||||
*/
|
||||
export async function uploadDocument(params: {
|
||||
ownerType:
|
||||
| "APPLICATION"
|
||||
| "APPLICATION_STAFF"
|
||||
| "INSPECTION"
|
||||
| "LICENSE"
|
||||
| "SEA_SERVICE_RECORD"
|
||||
| "MEDICAL_CERTIFICATE";
|
||||
| 'APPLICATION'
|
||||
| 'APPLICATION_STAFF'
|
||||
| 'INSPECTION'
|
||||
| 'LICENSE'
|
||||
| 'SEA_SERVICE_RECORD'
|
||||
| 'MEDICAL_CERTIFICATE'
|
||||
| 'SEAFARER_REGISTRATION';
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
file: File;
|
||||
@@ -35,17 +37,17 @@ export async function uploadDocument(params: {
|
||||
validTo?: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const form = new FormData();
|
||||
form.append("ownerType", params.ownerType);
|
||||
form.append("ownerId", params.ownerId);
|
||||
form.append("documentKey", params.documentKey);
|
||||
if (params.title) form.append("title", params.title);
|
||||
if (params.validFrom) form.append("validFrom", params.validFrom);
|
||||
if (params.validTo) form.append("validTo", params.validTo);
|
||||
form.append("files", params.file, params.file.name);
|
||||
form.append('ownerType', params.ownerType);
|
||||
form.append('ownerId', params.ownerId);
|
||||
form.append('documentKey', params.documentKey);
|
||||
if (params.title) form.append('title', params.title);
|
||||
if (params.validFrom) form.append('validFrom', params.validFrom);
|
||||
if (params.validTo) form.append('validTo', params.validTo);
|
||||
form.append('files', params.file, params.file.name);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
const res = await fetch(`${BASE_API_URL}/attachments`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
@@ -56,40 +58,55 @@ export async function uploadDocument(params: {
|
||||
|
||||
/** Human label for a status, in the vocabulary the user stories use. */
|
||||
export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
DRAFT: "Draft",
|
||||
SUBMITTED: "Submitted",
|
||||
UNDER_REVIEW: "Under Review",
|
||||
UNDER_EVALUATION: "Under Evaluation",
|
||||
RESUBMIT_REQUIRED: "Resubmit Required",
|
||||
INSPECTION_PENDING: "Inspection Pending",
|
||||
INSPECTION_COMPLETED: "Inspection Completed",
|
||||
APPROVED: "Approved",
|
||||
REJECTED: "Rejected",
|
||||
ON_HOLD: "On Hold",
|
||||
PAYMENT_PENDING: "Payment Pending",
|
||||
PAID: "Paid",
|
||||
PAYMENT_CONFIRMED: "Preparing Certificate",
|
||||
CERTIFICATE_ISSUED: "Certificate Issued",
|
||||
COMPLETED: "Completed",
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
SCHEDULED: 'Pickup Scheduled',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
ELIGIBILITY_APPROVED: 'Eligible to Sit',
|
||||
EXAM_PAYMENT_PENDING: 'Exam Fee Due',
|
||||
EXAM_PAID: 'Awaiting Exam Date',
|
||||
EXAM_SCHEDULED: 'Exam Scheduled',
|
||||
EXAM_PASSED: 'Exam Passed',
|
||||
EXAM_FAILED: 'Exam Not Passed',
|
||||
};
|
||||
|
||||
/** Mantine colour per status — green progresses, orange needs the applicant. */
|
||||
export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
DRAFT: "gray",
|
||||
SUBMITTED: "blue",
|
||||
UNDER_REVIEW: "indigo",
|
||||
UNDER_EVALUATION: "indigo",
|
||||
RESUBMIT_REQUIRED: "orange",
|
||||
INSPECTION_PENDING: "cyan",
|
||||
INSPECTION_COMPLETED: "cyan",
|
||||
APPROVED: "teal",
|
||||
REJECTED: "red",
|
||||
ON_HOLD: "gray",
|
||||
PAYMENT_PENDING: "yellow",
|
||||
PAID: "lime",
|
||||
PAYMENT_CONFIRMED: "teal",
|
||||
CERTIFICATE_ISSUED: "green",
|
||||
COMPLETED: "green",
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
INSPECTION_COMPLETED: 'cyan',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ON_HOLD: 'gray',
|
||||
PAYMENT_PENDING: 'yellow',
|
||||
PAID: 'lime',
|
||||
PAYMENT_CONFIRMED: 'teal',
|
||||
SCHEDULED: 'cyan',
|
||||
CERTIFICATE_ISSUED: 'green',
|
||||
COMPLETED: 'green',
|
||||
ELIGIBILITY_APPROVED: 'teal',
|
||||
EXAM_PAYMENT_PENDING: 'yellow',
|
||||
EXAM_PAID: 'lime',
|
||||
EXAM_SCHEDULED: 'cyan',
|
||||
EXAM_PASSED: 'teal',
|
||||
// Orange, not red: a failure is recoverable here — the candidate resits.
|
||||
EXAM_FAILED: 'orange',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -112,6 +129,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
PAYMENT_PENDING: 80,
|
||||
PAID: 88,
|
||||
PAYMENT_CONFIRMED: 94,
|
||||
SCHEDULED: 97,
|
||||
CERTIFICATE_ISSUED: 100,
|
||||
COMPLETED: 100,
|
||||
REJECTED: 100,
|
||||
@@ -128,59 +146,180 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
|
||||
/** Statuses where nothing moves until the applicant does something. */
|
||||
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
"DRAFT",
|
||||
"RESUBMIT_REQUIRED",
|
||||
"PAYMENT_PENDING",
|
||||
'DRAFT',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'PAYMENT_PENDING',
|
||||
// Both wait on the candidate: one to pay for a sitting, one to decide to
|
||||
// sit again after a failure.
|
||||
'EXAM_PAYMENT_PENDING',
|
||||
'EXAM_FAILED',
|
||||
];
|
||||
|
||||
/** Statuses that are finished, whichever way they went. */
|
||||
export const TERMINAL_STATUSES: LicenseStatus[] = [
|
||||
"CERTIFICATE_ISSUED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
/** Licence types with no `companyName` — these are filed by a person, not a
|
||||
* business, so display falls back to the applicant name captured in the form. */
|
||||
export const APPLICANT_NAME_TYPE_KEYS = [
|
||||
"SEAFARER_REGISTRATION",
|
||||
"CERTIFICATE_OF_COMPETENCY",
|
||||
"CERTIFICATE_OF_PROFICIENCY",
|
||||
"VESSEL_REGISTRATION",
|
||||
"VESSEL_OWNERSHIP_TRANSFER",
|
||||
"ENDORSEMENT_COC",
|
||||
"ENDORSEMENT_GOC",
|
||||
'SEAFARER_REGISTRATION',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
|
||||
/** Company name, or applicant name for licence types that have no company. */
|
||||
export function applicantOrCompanyName(
|
||||
app: LicenseApplication,
|
||||
): string | undefined {
|
||||
if (
|
||||
!app.licenseType?.key ||
|
||||
!APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)
|
||||
) {
|
||||
const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
||||
SEAFARER_REGISTRATION: 'DOCUMENT',
|
||||
SEAMAN_BOOK: 'DOCUMENT',
|
||||
VESSEL_REGISTRATION: 'DOCUMENT',
|
||||
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
||||
ENDORSEMENT_COC: 'CERTIFICATE',
|
||||
ENDORSEMENT_GOC: 'CERTIFICATE',
|
||||
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
||||
SHIPPING_AGENT: 'LOGISTICS_LICENSE',
|
||||
COMBINED_SA_FF: 'LOGISTICS_LICENSE',
|
||||
MULTIMODAL_TRANSPORT_OPERATOR: 'LOGISTICS_LICENSE',
|
||||
JOINT_INVESTOR: 'LOGISTICS_LICENSE',
|
||||
};
|
||||
|
||||
/**
|
||||
* `FamilyKind` for a type key, for data that predates `familyKind` being a
|
||||
* real column on the server (cached responses from before the rollout, or
|
||||
* anything that only ever had a bare key to go on). Every fresh response now
|
||||
* carries `familyKind` directly from the server's own `family_kind` column —
|
||||
* prefer that over calling this. Kept only as a fallback, and only for keys
|
||||
* this map happens to know about; falls back further to `LOGISTICS_LICENSE`
|
||||
* for anything else, reproducing today's "Licence ___" wording — the safe
|
||||
* default for an unrecognised key.
|
||||
*/
|
||||
export function resolveFamilyKind(licenseTypeKey: string | undefined | null): FamilyKind {
|
||||
return (licenseTypeKey && FAMILY_KIND_BY_KEY[licenseTypeKey]) || 'LOGISTICS_LICENSE';
|
||||
}
|
||||
|
||||
export interface FamilyLabels {
|
||||
/** e.g. "Certificate", "Document", "Licence". */
|
||||
typeLabel: string;
|
||||
/** e.g. "Certificate Number", "Document Number", "Licence Number". */
|
||||
numberLabel: string;
|
||||
/** e.g. "Certificate Holder", "Document Holder", "Licence Holder". */
|
||||
holderLabel: string;
|
||||
/** e.g. "Certificate Review", "Document Review", "Licence Review". */
|
||||
reviewLabel: string;
|
||||
}
|
||||
|
||||
const FAMILY_LABELS: Record<FamilyKind, FamilyLabels> = {
|
||||
CERTIFICATE: {
|
||||
typeLabel: 'Certificate',
|
||||
numberLabel: 'Certificate Number',
|
||||
holderLabel: 'Certificate Holder',
|
||||
reviewLabel: 'Certificate Review',
|
||||
},
|
||||
DOCUMENT: {
|
||||
typeLabel: 'Document',
|
||||
numberLabel: 'Document Number',
|
||||
holderLabel: 'Document Holder',
|
||||
reviewLabel: 'Document Review',
|
||||
},
|
||||
LOGISTICS_LICENSE: {
|
||||
typeLabel: 'Licence',
|
||||
numberLabel: 'Licence Number',
|
||||
holderLabel: 'Licence Holder',
|
||||
reviewLabel: 'Licence Review',
|
||||
},
|
||||
};
|
||||
|
||||
export function familyLabels(familyKind: FamilyKind): FamilyLabels {
|
||||
return FAMILY_LABELS[familyKind];
|
||||
}
|
||||
|
||||
/**
|
||||
* Company name, or applicant name for applications with no company.
|
||||
*
|
||||
* Branches on `familyKind` — the real data-model column — rather than a
|
||||
* hand-maintained key list. A certificate or document is filed by a person,
|
||||
* never a business, so it never has a `companyName` to show; a logistics
|
||||
* licence always does. This is what used to be `APPLICANT_NAME_TYPE_KEYS`, a
|
||||
* frontend array that had to be remembered and kept in sync by hand every
|
||||
* time a new certificate/document type was added — it silently missed
|
||||
* `SEAMAN_BOOK`/`BTC_BASIC_TRAINING`, which is why those rows rendered "—"
|
||||
* instead of the applicant's name in the backoffice queue. `familyKind` is
|
||||
* correct for every current and future type without a matching array update.
|
||||
*/
|
||||
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
|
||||
if (app.familyKind === 'LOGISTICS_LICENSE') {
|
||||
return app.companyName ?? undefined;
|
||||
}
|
||||
const applicantName = (
|
||||
app.formData?.account as Record<string, unknown> | undefined
|
||||
)?.applicantName;
|
||||
return typeof applicantName === "string" && applicantName
|
||||
? applicantName
|
||||
: undefined;
|
||||
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
|
||||
?.applicantName;
|
||||
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One form answer as a person should read it back.
|
||||
*
|
||||
* The stored value is not it: a SELECT holds the option's `value`, so an
|
||||
* unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
|
||||
* the codes the database wants, not the words that were chosen. Shared by the
|
||||
* applicant's summary and the officer's review so the two never describe the
|
||||
* same application differently.
|
||||
*
|
||||
* `showDate` is passed in rather than imported: date display is a hook
|
||||
* (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
|
||||
*/
|
||||
export function displayFieldValue(
|
||||
field: Pick<FormFieldConfig, 'type' | 'options'>,
|
||||
raw: unknown,
|
||||
opts: {
|
||||
language?: string;
|
||||
showDate?: (value: string) => string;
|
||||
currency?: string;
|
||||
} = {},
|
||||
): string {
|
||||
if (raw === null || raw === undefined || raw === '') return '';
|
||||
const { language = 'en', showDate, currency } = opts;
|
||||
|
||||
switch (field.type) {
|
||||
case 'BOOLEAN':
|
||||
return raw ? 'Yes' : 'No';
|
||||
case 'DATE':
|
||||
return showDate?.(String(raw)) || String(raw);
|
||||
case 'SELECT': {
|
||||
const option = field.options?.find((o) => o.value === raw);
|
||||
// Falls back to the stored value rather than blanking: an option removed
|
||||
// from the config since this was filed still has to show what was chosen.
|
||||
return option ? localized(option.label, language) : String(raw);
|
||||
}
|
||||
case 'MONEY': {
|
||||
const amount = Number(raw);
|
||||
return Number.isFinite(amount)
|
||||
? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
|
||||
: String(raw);
|
||||
}
|
||||
default:
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
export function localized(
|
||||
value: Bilingual | undefined,
|
||||
language = "en",
|
||||
): string {
|
||||
if (!value) return "";
|
||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||
if (!value) return '';
|
||||
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
||||
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
|
||||
return (
|
||||
(language === "am" ? value.am : value.en) || value.en || value.am || ""
|
||||
);
|
||||
//
|
||||
// Keyed by the active language rather than an en/am ternary, so a locale the
|
||||
// backend stores but the UI does not yet offer a switcher for (`om`, `so` on
|
||||
// location names) still resolves once it does. English then Amharic remain
|
||||
// the fallbacks, in that order.
|
||||
const active = value[language as keyof Bilingual];
|
||||
return active || value.en || value.am || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +329,7 @@ export function localized(
|
||||
export function extractValidationIssues(error: unknown): ValidationIssue[] {
|
||||
const data = (error as { data?: Record<string, unknown> })?.data;
|
||||
if (!data) return [];
|
||||
const issues = data["issues"];
|
||||
const issues = data['issues'];
|
||||
return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
|
||||
}
|
||||
|
||||
@@ -202,54 +341,47 @@ export function extractValidationIssues(error: unknown): ValidationIssue[] {
|
||||
*/
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
capital_verification_required:
|
||||
"Record the verified capital before approving — it must be checked against the bank letter.",
|
||||
'Record the verified capital before approving — it must be checked against the bank letter.',
|
||||
capital_below_threshold:
|
||||
"The verified capital is below the minimum required for this licence type.",
|
||||
'The verified capital is below the minimum required for this licence type.',
|
||||
inspection_required_before_approval:
|
||||
"A physical inspection must be recorded before this application can be approved.",
|
||||
application_already_claimed:
|
||||
"Another officer has already claimed this application.",
|
||||
'A physical inspection must be recorded before this application can be approved.',
|
||||
application_already_claimed: 'Another officer has already claimed this application.',
|
||||
assigned_to_another_officer:
|
||||
"This application is assigned to another officer, so only they can act on it.",
|
||||
not_application_owner: "This application belongs to another applicant.",
|
||||
application_incomplete: "The application is missing required information.",
|
||||
unresolved_remarks: "Every requested correction must be addressed first.",
|
||||
adjustment_items_required:
|
||||
"Flag at least one item before requesting an adjustment.",
|
||||
remark_text_required:
|
||||
"Each flagged item needs a remark explaining what to fix.",
|
||||
rejection_reason_required: "A rejection reason is required.",
|
||||
'This application is assigned to another officer, so only they can act on it.',
|
||||
not_application_owner: 'This application belongs to another applicant.',
|
||||
application_incomplete: 'The application is missing required information.',
|
||||
unresolved_remarks: 'Every requested correction must be addressed first.',
|
||||
adjustment_items_required: 'Flag at least one item before requesting an adjustment.',
|
||||
remark_text_required: 'Each flagged item needs a remark explaining what to fix.',
|
||||
rejection_reason_required: 'A rejection reason is required.',
|
||||
section_not_flagged_for_adjustment:
|
||||
"Only the sections the officer flagged can be changed in this round.",
|
||||
application_not_editable: "This application can no longer be edited.",
|
||||
invalid_transition: "That action is not available at this stage.",
|
||||
'Only the sections the officer flagged can be changed in this round.',
|
||||
application_not_editable: 'This application can no longer be edited.',
|
||||
invalid_transition: 'That action is not available at this stage.',
|
||||
inspection_not_required_use_final_approve:
|
||||
"This licence type needs no inspection — approve it directly.",
|
||||
'This licence type needs no inspection — approve it directly.',
|
||||
application_not_awaiting_inspection:
|
||||
"This application is not waiting for an inspection.",
|
||||
inspection_already_completed: "This inspection has already been recorded.",
|
||||
license_type_inactive:
|
||||
"This licence type is not currently accepting applications.",
|
||||
'This application is not waiting for an inspection.',
|
||||
inspection_already_completed: 'This inspection has already been recorded.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
};
|
||||
|
||||
export function extractErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Something went wrong",
|
||||
): string {
|
||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||
const data = (error as { data?: Record<string, unknown> })?.data;
|
||||
const raw = data?.["message"];
|
||||
const raw = data?.['message'];
|
||||
|
||||
// The filter passes structured errors through as `{ message: { message } }`.
|
||||
const key =
|
||||
typeof raw === "string"
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
: typeof (raw as { message?: unknown })?.message === "string"
|
||||
? (raw as { message: string }).message
|
||||
: typeof (raw as { message?: unknown })?.message === 'string'
|
||||
? ((raw as { message: string }).message)
|
||||
: undefined;
|
||||
|
||||
if (key && ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
|
||||
if (typeof raw === "string") return raw;
|
||||
if (Array.isArray(raw)) return raw.join(", ");
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -257,7 +389,7 @@ export function extractErrorMessage(
|
||||
export interface WizardStep {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "sections" | "staff" | "documents" | "review";
|
||||
kind: 'sections' | 'staff' | 'documents' | 'review';
|
||||
sections: FormSectionConfig[];
|
||||
}
|
||||
|
||||
@@ -268,6 +400,16 @@ export interface WizardStep {
|
||||
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
|
||||
* type that has not been grouped still behaves exactly as before.
|
||||
*/
|
||||
/** "identitySummary" -> "Identity Summary". Last resort for an untitled group. */
|
||||
function humanise(key: string): string {
|
||||
return key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function buildWizardSteps(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
@@ -296,7 +438,7 @@ export function buildWizardSteps(
|
||||
steps.push({
|
||||
key: `section:${section.key}`,
|
||||
label: localized(section.title, options?.language),
|
||||
kind: "sections",
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
});
|
||||
continue;
|
||||
@@ -308,8 +450,12 @@ export function buildWizardSteps(
|
||||
}
|
||||
const step: WizardStep = {
|
||||
key: `group:${group}`,
|
||||
label: group,
|
||||
kind: "sections",
|
||||
// A group is a config key, not a caption — showing it raw put
|
||||
// "identitySummary" and "applicant" in front of applicants. The first
|
||||
// section's own title is the readable name for the step it opens; the
|
||||
// humanised key is the fallback when a section carries no title.
|
||||
label: localized(section.title, options?.language) || humanise(group),
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
};
|
||||
byGroup.set(group, step);
|
||||
@@ -325,7 +471,7 @@ export function buildWizardSteps(
|
||||
// beside the summary rather than earning a step of their own. It is what
|
||||
// keeps a one-checkbox declaration from costing a whole page.
|
||||
const isReviewGroup = (step: WizardStep) =>
|
||||
step.sections.some((s) => s.group?.trim().toLowerCase() === "review");
|
||||
step.sections.some((s) => s.group?.trim().toLowerCase() === 'review');
|
||||
const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
|
||||
const formSteps = steps.filter((step) => !isReviewGroup(step));
|
||||
|
||||
@@ -333,21 +479,9 @@ export function buildWizardSteps(
|
||||
...formSteps,
|
||||
...(options?.hasStaff === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "staff",
|
||||
label: "Staff",
|
||||
kind: "staff",
|
||||
sections: [],
|
||||
} as WizardStep,
|
||||
]),
|
||||
{ key: "documents", label: "Documents", kind: "documents", sections: [] },
|
||||
{
|
||||
key: "review",
|
||||
label: "Review",
|
||||
kind: "review",
|
||||
sections: reviewSections,
|
||||
},
|
||||
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
|
||||
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
|
||||
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -362,7 +496,7 @@ export type FieldErrors = Record<string, string>;
|
||||
export function validateSections(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
language = "en",
|
||||
language = 'en',
|
||||
): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
@@ -379,13 +513,13 @@ export function validateSections(
|
||||
const empty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && value.trim() === "") ||
|
||||
(typeof value === "boolean" && value === false) ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(typeof value === 'boolean' && value === false) ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (field.required && empty) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
field.type === "BOOLEAN"
|
||||
field.type === 'BOOLEAN'
|
||||
? `${localized(field.label, language)} must be accepted`
|
||||
: `${localized(field.label, language)} is required`;
|
||||
continue;
|
||||
@@ -410,33 +544,23 @@ export function validateSections(
|
||||
/** Evaluates a config condition against the current form answers. */
|
||||
export function conditionHolds(
|
||||
condition:
|
||||
| {
|
||||
field: string;
|
||||
equals?: unknown;
|
||||
notEquals?: unknown;
|
||||
in?: (string | number)[];
|
||||
isSet?: boolean;
|
||||
}
|
||||
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
|
||||
| undefined
|
||||
| null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!condition?.field) return true;
|
||||
const value = condition.field
|
||||
.split(".")
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(acc, key) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[key]
|
||||
: undefined,
|
||||
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
|
||||
formData as unknown,
|
||||
);
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
if (condition.isSet !== undefined)
|
||||
return condition.isSet ? !isEmpty : isEmpty;
|
||||
const isEmpty = value === undefined || value === null || value === '';
|
||||
if (condition.isSet !== undefined) return condition.isSet ? !isEmpty : isEmpty;
|
||||
if (condition.equals !== undefined) return value === condition.equals;
|
||||
if (condition.notEquals !== undefined) return value !== condition.notEquals;
|
||||
if (condition.in !== undefined)
|
||||
return condition.in.includes(value as string | number);
|
||||
if (condition.in !== undefined) return condition.in.includes(value as string | number);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
// Reused rather than redeclared: the department vocabulary belongs to the
|
||||
// seafarer domain, and two copies would drift.
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
import type { SeafarerDepartment } from "../seafarer/seafarer.types";
|
||||
|
||||
export type Bilingual = { en?: string; am?: string };
|
||||
/**
|
||||
* A backend `LocaleValidationDto`. Named for the two locales the UI offers, but
|
||||
* carries every locale the column stores — location names are seeded with `om`
|
||||
* (Finfinnee) and `so` too, and a type that declared only en/am made those
|
||||
* unreachable through the typed client.
|
||||
*/
|
||||
export type Bilingual = {
|
||||
en?: string;
|
||||
am?: string;
|
||||
om?: string;
|
||||
so?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The application status vocabulary. Single source of truth for both apps —
|
||||
@@ -24,6 +35,10 @@ export type LicenseStatus =
|
||||
| "PAYMENT_PENDING"
|
||||
| "PAID"
|
||||
| "PAYMENT_CONFIRMED"
|
||||
// Payment confirmed and pickup date set — for a document printed once and
|
||||
// handed over in person (seaman book, BTC). Most license types skip this
|
||||
// and go straight from PAYMENT_CONFIRMED to CERTIFICATE_ISSUED.
|
||||
| "SCHEDULED"
|
||||
| "CERTIFICATE_ISSUED"
|
||||
| "COMPLETED"
|
||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
||||
@@ -87,12 +102,25 @@ export interface FormSectionConfig {
|
||||
|
||||
/** Grouping the portal organises the licence catalogue by. */
|
||||
export type LicenseCategory =
|
||||
| 'CARGO_FREIGHT'
|
||||
| 'SHIPPING_AGENCY'
|
||||
| 'INVESTMENT'
|
||||
| 'MARITIME_PERSONNEL'
|
||||
| 'VESSEL_SERVICES'
|
||||
| 'WAIVER_SERVICES';
|
||||
| "CARGO_FREIGHT"
|
||||
| "SHIPPING_AGENCY"
|
||||
| "INVESTMENT"
|
||||
| "MARITIME_PERSONNEL"
|
||||
| "VESSEL_SERVICES"
|
||||
| "WAIVER_SERVICES";
|
||||
|
||||
/**
|
||||
* Which business concept a licence type actually is — the real data-model
|
||||
* classification (emaapi's `license_types.family_kind` column), not a label
|
||||
* computed from `key`. A permission granted to a logistics operator, a
|
||||
* seafarer's proof of competence, and a seafarer/vessel's identity or
|
||||
* statutory record are three different things to the people using this
|
||||
* system, even though they run through the identical application pipeline —
|
||||
* see BR-MTO-020. Drives terminology, navigation, and which columns a grid
|
||||
* shows (Company/TIN only make sense for LOGISTICS_LICENSE rows) — never a
|
||||
* workflow or eligibility branch.
|
||||
*/
|
||||
export type FamilyKind = "LOGISTICS_LICENSE" | "CERTIFICATE" | "DOCUMENT";
|
||||
|
||||
export interface LicenseCategoryDefinition {
|
||||
key: LicenseCategory;
|
||||
@@ -120,6 +148,8 @@ export interface LicenseType {
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
category: LicenseCategory;
|
||||
/** The real data-model classification — see `FamilyKind`. */
|
||||
familyKind: FamilyKind;
|
||||
certificatePrefix: string;
|
||||
feeNewApplication: string | number | null;
|
||||
feeRenewal: string | number | null;
|
||||
@@ -134,6 +164,8 @@ export interface LicenseType {
|
||||
inspectionRequired: boolean;
|
||||
issuesCertificate: boolean;
|
||||
renewalEnabled: boolean;
|
||||
/** Payment confirmation waits for a scheduled pickup date before issuance. */
|
||||
requiresIssuanceScheduling: boolean;
|
||||
/**
|
||||
* False for person-centric registrations (seafarer): they are open to any
|
||||
* authenticated applicant, live outside the operator catalogue, and have
|
||||
@@ -141,6 +173,11 @@ export interface LicenseType {
|
||||
*/
|
||||
requiresOperatorMode: boolean;
|
||||
formSchema: { sections: FormSectionConfig[] };
|
||||
/**
|
||||
* Which course an application of this type runs. REGISTRATION skips the
|
||||
* evaluation and inspection stages, so those statuses are unreachable for it.
|
||||
*/
|
||||
workflowProfile?: WorkflowProfile;
|
||||
isActive: boolean;
|
||||
/** Display order set by EMA; lower comes first. */
|
||||
sortOrder: number;
|
||||
@@ -170,11 +207,7 @@ export interface LicenseType {
|
||||
|
||||
/** What kind of document a certificate type produces. */
|
||||
export type CertificateCategory =
|
||||
| "COC"
|
||||
| "COP"
|
||||
| "ENDORSEMENT"
|
||||
| "GOC"
|
||||
| "NATIONAL";
|
||||
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
|
||||
|
||||
/**
|
||||
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
|
||||
@@ -244,7 +277,10 @@ export interface LicenseApplication {
|
||||
applicationNumber: string;
|
||||
licenseTypeId: string;
|
||||
licenseType?: LicenseType;
|
||||
/** Denormalized from `licenseType.familyKind` at submission time. */
|
||||
familyKind: FamilyKind;
|
||||
applicantUserId: string;
|
||||
parentApplicationId?: string | null;
|
||||
kind: ApplicationKind;
|
||||
status: LicenseStatus;
|
||||
assignedOfficerId: string | null;
|
||||
@@ -263,6 +299,9 @@ export interface LicenseApplication {
|
||||
feeAmount: string | null;
|
||||
feeCurrency: string;
|
||||
issuedLicenseId: string | null;
|
||||
/** Set once an officer schedules pickup for a document requiring in-person handover. */
|
||||
scheduledIssuanceDate: string | null;
|
||||
scheduledBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -324,8 +363,38 @@ export interface ApplicationRemark {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who filed the application, from their profile.
|
||||
*
|
||||
* Served alongside the form because a person-centric service (seafarer
|
||||
* registration, a certificate) has no `companyName` to identify itself by — a
|
||||
* reviewer opening one otherwise sees only an application number and has to
|
||||
* infer the human from the answers.
|
||||
*/
|
||||
export interface ApplicationApplicant {
|
||||
profileId: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
gender: string | null;
|
||||
dob: string | null;
|
||||
pob: string | null;
|
||||
maritalStatus: string | null;
|
||||
seafarerNumber: string | null;
|
||||
seafarerStatus: string | null;
|
||||
seafarerDepartment: string | null;
|
||||
nationality: string | null;
|
||||
idType: string | null;
|
||||
idNumber: string | null;
|
||||
primaryPhoneNumber: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
relatedApplications?: LicenseApplication[];
|
||||
/** Null when the applicant has no profile row (never expected in practice). */
|
||||
applicant: ApplicationApplicant | null;
|
||||
staff: ApplicationStaff[];
|
||||
attachments: Attachment[];
|
||||
history: StatusHistoryEntry[];
|
||||
@@ -382,6 +451,9 @@ export type QueueSortField =
|
||||
| "dueAt"
|
||||
| "claimedAt";
|
||||
|
||||
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
unassigned: number;
|
||||
@@ -440,11 +512,7 @@ export interface TemplatePageOptions {
|
||||
|
||||
/** Corner the institute logo is anchored to. */
|
||||
export type TemplateLogoCorner =
|
||||
| 'TOP_LEFT'
|
||||
| 'TOP_CENTER'
|
||||
| 'TOP_RIGHT'
|
||||
| 'BOTTOM_LEFT'
|
||||
| 'BOTTOM_RIGHT';
|
||||
"TOP_LEFT" | "TOP_CENTER" | "TOP_RIGHT" | "BOTTOM_LEFT" | "BOTTOM_RIGHT";
|
||||
|
||||
/** Where the institute logo sits on the certificate. */
|
||||
export interface TemplateLogoPlacement {
|
||||
@@ -471,8 +539,8 @@ export interface TemplateFieldPlacement {
|
||||
yPct: number;
|
||||
widthPct: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: 'normal' | 'bold';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
fontWeight?: "normal" | "bold";
|
||||
align?: "left" | "center" | "right";
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@@ -545,7 +613,8 @@ export interface InitiatePaymentResult {
|
||||
|
||||
export interface ApplicationPayment {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
applicationId: string | null;
|
||||
documentId?: string | null;
|
||||
paymentIntentId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
@@ -568,6 +637,8 @@ export interface IssuedLicense {
|
||||
certificateNumber: string;
|
||||
licenseTypeId: string;
|
||||
licenseType?: LicenseType;
|
||||
/** Denormalized from `licenseType.familyKind` at issuance time. */
|
||||
familyKind: FamilyKind;
|
||||
applicationId: string;
|
||||
companyName: string | null;
|
||||
tinNumber: string | null;
|
||||
|
||||
1
libs/api/src/lib/features/location/index.ts
Normal file
1
libs/api/src/lib/features/location/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './location.types';
|
||||
47
libs/api/src/lib/features/location/location.types.ts
Normal file
47
libs/api/src/lib/features/location/location.types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** Shared location contract — mirrors the `iam.locations` tree in emaapi. */
|
||||
|
||||
import type { Bilingual } from '../licensing/licensing.types';
|
||||
|
||||
/**
|
||||
* One node of the location tree.
|
||||
*
|
||||
* Both apps read the same `/locations` route, so the model lives here rather
|
||||
* than in each app's own feature folder — the two hand-maintained copies had
|
||||
* already drifted (the portal's lacked `locationType`, `children` and the
|
||||
* timestamps, and its query accepted no `parentId` filter even though the
|
||||
* backend supports one).
|
||||
*
|
||||
* `names` is `Bilingual`, the backend's `LocaleValidationDto`: seeded location
|
||||
* names carry `om` and `so` alongside `en`/`am`, which the previous
|
||||
* `{ en, am }` pair made unreachable.
|
||||
*/
|
||||
export interface Location {
|
||||
id: string;
|
||||
code: string;
|
||||
names: Bilingual;
|
||||
locationTypeId: string;
|
||||
parentId: string | null;
|
||||
locationType?: LocationType;
|
||||
children?: Location[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A level in the tree. `level` orders them (City 1 → Sub-city 2 → Woreda 3 →
|
||||
* Kebele 4); `code` is what both sides key behaviour off, so it is the field to
|
||||
* match on rather than the display name.
|
||||
*/
|
||||
export interface LocationType {
|
||||
id: string;
|
||||
code: string;
|
||||
names: Bilingual;
|
||||
level: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
3
libs/api/src/lib/features/seafarer-document/index.ts
Normal file
3
libs/api/src/lib/features/seafarer-document/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './seafarer-document.types';
|
||||
export * from './seafarer-document.constants';
|
||||
export * from './seafarer-document-api';
|
||||
@@ -0,0 +1,131 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type { ApplicationPayment, InitiatePaymentResult } from '../licensing/licensing.types';
|
||||
import type {
|
||||
SeafarerDocument,
|
||||
SeafarerDocumentDetail,
|
||||
SeafarerDocumentKind,
|
||||
SeafarerDocumentRow,
|
||||
SeafarerDocumentStatus,
|
||||
} from './seafarer-document.types';
|
||||
|
||||
const TAG = 'SeafarerDocument' as const;
|
||||
const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
const item = (id: string) => ({ type: TAG, id }) as const;
|
||||
|
||||
export interface SeafarerDocumentListFilter {
|
||||
kind?: SeafarerDocumentKind;
|
||||
status?: SeafarerDocumentStatus;
|
||||
search?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seaman Book and BTC — own endpoints, not licence applications. Fees settle
|
||||
* through the same payment gateway, under `/seafarer-documents/:id/payments`.
|
||||
*/
|
||||
export const seafarerDocumentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// ------------------------------------------------------------ applicant
|
||||
getMySeafarerDocuments: builder.query<
|
||||
{ seamanBook: SeafarerDocument | null; btc: SeafarerDocument | null },
|
||||
void
|
||||
>({
|
||||
query: () => ({ url: '/seafarer-documents/mine' }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
getMySeafarerDocumentDownload: builder.query<{ url: string }, string>({
|
||||
query: (id) => ({ url: `/seafarer-documents/${id}/download` }),
|
||||
}),
|
||||
|
||||
initiateDocumentPayment: builder.mutation<
|
||||
InitiatePaymentResult,
|
||||
{ id: string; provider?: string; platform?: 'web' | 'mobile'; payerAccount?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-documents/${id}/payments/initiate`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
getDocumentPayment: builder.query<ApplicationPayment, string>({
|
||||
query: (id) => ({ url: `/seafarer-documents/${id}/payments` }),
|
||||
providesTags: (_r, _e, id) => [item(id)],
|
||||
}),
|
||||
|
||||
bypassDocumentPayment: builder.mutation<{ status: SeafarerDocumentStatus }, string>({
|
||||
query: (id) => ({ url: `/seafarer-documents/${id}/payments/bypass`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------------- review
|
||||
listSeafarerDocuments: builder.query<
|
||||
{ total: number; items: SeafarerDocumentRow[] },
|
||||
SeafarerDocumentListFilter
|
||||
>({
|
||||
query: (params) => ({ url: '/seafarer-document-review', params }),
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
getSeafarerDocumentReview: builder.query<SeafarerDocumentDetail, string>({
|
||||
query: (id) => ({ url: `/seafarer-document-review/${id}` }),
|
||||
providesTags: (_r, _e, id) => [item(id)],
|
||||
}),
|
||||
|
||||
getSeafarerDocumentReviewDownload: builder.query<{ url: string }, string>({
|
||||
query: (id) => ({ url: `/seafarer-document-review/${id}/download` }),
|
||||
}),
|
||||
|
||||
confirmSeafarerDocumentPayment: builder.mutation<SeafarerDocument, string>({
|
||||
query: (id) => ({ url: `/seafarer-document-review/${id}/confirm-payment`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
scheduleSeafarerDocument: builder.mutation<
|
||||
SeafarerDocument,
|
||||
{ id: string; scheduledDate: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-document-review/${id}/schedule-issuance`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
issueSeafarerDocument: builder.mutation<SeafarerDocument, string>({
|
||||
query: (id) => ({ url: `/seafarer-document-review/${id}/issue`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
rejectSeafarerDocument: builder.mutation<SeafarerDocument, { id: string; reason: string }>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/seafarer-document-review/${id}/reject`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetMySeafarerDocumentsQuery,
|
||||
useLazyGetMySeafarerDocumentDownloadQuery,
|
||||
useInitiateDocumentPaymentMutation,
|
||||
useGetDocumentPaymentQuery,
|
||||
useBypassDocumentPaymentMutation,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useGetSeafarerDocumentReviewQuery,
|
||||
useLazyGetSeafarerDocumentReviewDownloadQuery,
|
||||
useConfirmSeafarerDocumentPaymentMutation,
|
||||
useScheduleSeafarerDocumentMutation,
|
||||
useIssueSeafarerDocumentMutation,
|
||||
useRejectSeafarerDocumentMutation,
|
||||
} = seafarerDocumentApi;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
|
||||
|
||||
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
|
||||
SEAMAN_BOOK: 'Seaman Book',
|
||||
BTC_BASIC_TRAINING: 'Basic Training Certificate',
|
||||
};
|
||||
|
||||
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
|
||||
AWAITING_REGISTRATION: 'Awaiting Registration',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Payment Confirmed',
|
||||
SCHEDULED: 'Pickup Scheduled',
|
||||
ISSUED: 'Issued',
|
||||
REJECTED: 'Rejected',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
export const SEAFARER_DOCUMENT_STATUS_COLORS: Record<SeafarerDocumentStatus, string> = {
|
||||
AWAITING_REGISTRATION: 'gray',
|
||||
PAYMENT_PENDING: 'orange',
|
||||
PAID: 'blue',
|
||||
PAYMENT_CONFIRMED: 'blue',
|
||||
SCHEDULED: 'grape',
|
||||
ISSUED: 'teal',
|
||||
REJECTED: 'red',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ApplicationPayment } from '../licensing/licensing.types';
|
||||
|
||||
export type SeafarerDocumentKind = 'SEAMAN_BOOK' | 'BTC_BASIC_TRAINING';
|
||||
|
||||
export type SeafarerDocumentStatus =
|
||||
| 'AWAITING_REGISTRATION'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'PAID'
|
||||
| 'PAYMENT_CONFIRMED'
|
||||
| 'SCHEDULED'
|
||||
| 'ISSUED'
|
||||
| 'REJECTED'
|
||||
| 'CANCELLED';
|
||||
|
||||
/** A Seaman Book or BTC request — opened by a seafarer registration. */
|
||||
export interface SeafarerDocument {
|
||||
id: string;
|
||||
kind: SeafarerDocumentKind;
|
||||
requestNumber: string;
|
||||
applicantUserId: string;
|
||||
profileId: string | null;
|
||||
seafarerRegistrationId: string | null;
|
||||
status: SeafarerDocumentStatus;
|
||||
feeAmount: number | null;
|
||||
feeCurrency: string;
|
||||
submittedAt: string | null;
|
||||
paidAt: string | null;
|
||||
paymentReference: string | null;
|
||||
scheduledIssuanceDate: string | null;
|
||||
documentNumber: string | null;
|
||||
issueDate: string | null;
|
||||
expiryDate: string | null;
|
||||
documentFileKey: string | null;
|
||||
issuedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SeafarerDocumentApplicant {
|
||||
name: string;
|
||||
seafarerNumber: string | null;
|
||||
registrationNumber: string | null;
|
||||
registrationId: string | null;
|
||||
}
|
||||
|
||||
export type SeafarerDocumentRow = SeafarerDocument & {
|
||||
applicant: SeafarerDocumentApplicant | null;
|
||||
};
|
||||
|
||||
export interface SeafarerDocumentDetail {
|
||||
document: SeafarerDocument;
|
||||
applicant: SeafarerDocumentApplicant | null;
|
||||
payment: ApplicationPayment | null;
|
||||
}
|
||||
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,118 @@
|
||||
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)],
|
||||
}),
|
||||
|
||||
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,
|
||||
useApproveSeafarerRegistrationMutation,
|
||||
useRejectSeafarerRegistrationMutation,
|
||||
useRequestSeafarerRegistrationChangesMutation,
|
||||
} = seafarerRegistrationApi;
|
||||
@@ -0,0 +1,209 @@
|
||||
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',
|
||||
RESUBMIT_REQUIRED: 'Corrections Requested',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
};
|
||||
|
||||
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
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,75 @@
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
|
||||
export type SeafarerRegistrationStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| '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;
|
||||
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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './seafarer.types';
|
||||
export * from './seafarer-api';
|
||||
export * from './seafarer.helpers';
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreateMedicalCertificate,
|
||||
CreateSeaServiceRecord,
|
||||
MedicalCertificate,
|
||||
RecordQueueFilter,
|
||||
SeaServiceRecord,
|
||||
SeaTimeSummary,
|
||||
SeafarerStatus,
|
||||
@@ -14,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 })
|
||||
@@ -104,13 +105,25 @@ export const seafarerApi = baseApi
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- verification
|
||||
getPendingSeaService: builder.query<SeaServiceRecord[], void>({
|
||||
query: () => ({ url: '/sea-service-records/pending' }),
|
||||
getPendingSeaService: builder.query<
|
||||
SeaServiceRecord[],
|
||||
RecordQueueFilter | void
|
||||
>({
|
||||
query: (status) => ({
|
||||
url: '/sea-service-records/pending',
|
||||
params: { status: status || 'SUBMITTED' },
|
||||
}),
|
||||
providesTags: () => [listTag('SeaServiceRecord')],
|
||||
}),
|
||||
|
||||
getPendingMedical: builder.query<MedicalCertificate[], void>({
|
||||
query: () => ({ url: '/medical-certificates/pending' }),
|
||||
getPendingMedical: builder.query<
|
||||
MedicalCertificate[],
|
||||
RecordQueueFilter | void
|
||||
>({
|
||||
query: (status) => ({
|
||||
url: '/medical-certificates/pending',
|
||||
params: { status: status || 'SUBMITTED' },
|
||||
}),
|
||||
providesTags: () => [listTag('MedicalCertificate')],
|
||||
}),
|
||||
|
||||
|
||||
23
libs/api/src/lib/features/seafarer/seafarer.helpers.ts
Normal file
23
libs/api/src/lib/features/seafarer/seafarer.helpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Days served on one engagement, counted inclusively — embarkation and
|
||||
* discharge days both count. The same arithmetic the API uses for approved
|
||||
* sea time (`SeafarerRecordService.approvedSeaTime`), so the figure a seafarer
|
||||
* sees while typing is the figure the eligibility gate will credit.
|
||||
*
|
||||
* `null` until both dates are set or while they are out of order.
|
||||
*/
|
||||
export function seaServiceDays(
|
||||
engagementDate: string | null | undefined,
|
||||
dischargeDate: string | null | undefined,
|
||||
): number | null {
|
||||
if (!engagementDate || !dischargeDate) return null;
|
||||
const from = Date.UTC(...dateParts(engagementDate));
|
||||
const to = Date.UTC(...dateParts(dischargeDate));
|
||||
if (Number.isNaN(from) || Number.isNaN(to) || to < from) return null;
|
||||
return Math.round((to - from) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
function dateParts(value: string): [number, number, number] {
|
||||
const [y, m, d] = value.slice(0, 10).split('-').map(Number);
|
||||
return [y, (m || 1) - 1, d || 1];
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
|
||||
|
||||
/** Verification-queue filter: a record status, or ALL for no filter. */
|
||||
export type RecordQueueFilter = SeafarerRecordStatus | 'ALL';
|
||||
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
|
||||
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
|
||||
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
CreateVesselIncident,
|
||||
Vessel,
|
||||
VesselIncident,
|
||||
VesselReport,
|
||||
VesselReportQuery,
|
||||
VesselStatus,
|
||||
} from './vessel.types';
|
||||
|
||||
@@ -35,6 +37,22 @@ export const vesselApi = baseApi
|
||||
providesTags: () => [listTag('Vessel')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* The whole backoffice dashboard in one call — KPIs, time series,
|
||||
* breakdowns and worklists. Backoffice only (`can:View:vessel-registry`).
|
||||
*
|
||||
* Array filters are passed as arrays, not joined strings: the API accepts
|
||||
* both the repeated and the comma-separated form, and `params` serialises
|
||||
* the repeated one.
|
||||
*/
|
||||
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
|
||||
query: (params) => ({
|
||||
url: '/vessels/report',
|
||||
params: params ?? undefined,
|
||||
}),
|
||||
providesTags: () => [listTag('Vessel')],
|
||||
}),
|
||||
|
||||
getVessel: builder.query<Vessel, string>({
|
||||
query: (id) => ({ url: `/vessels/${id}` }),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
|
||||
@@ -77,6 +95,7 @@ export const vesselApi = baseApi
|
||||
export const {
|
||||
useGetMyVesselsQuery,
|
||||
useGetVesselsQuery,
|
||||
useGetVesselReportQuery,
|
||||
useGetVesselQuery,
|
||||
useUpdateVesselStatusMutation,
|
||||
useGetVesselIncidentsQuery,
|
||||
|
||||
@@ -49,3 +49,247 @@ export interface CreateVesselIncident {
|
||||
description: string;
|
||||
severity?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vessel registration report (GET /vessels/report)
|
||||
//
|
||||
// One call fills the whole backoffice dashboard. Unlike `Vessel` above, every
|
||||
// numeric field here is already a real number — the API casts the Postgres
|
||||
// `numeric` strings before it answers.
|
||||
|
||||
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
|
||||
|
||||
/**
|
||||
* One slice of a breakdown chart.
|
||||
*
|
||||
* `percentage` is of the whole, not of the slices that survived the `topN`
|
||||
* cut, so a set of slices always totals 100.
|
||||
*/
|
||||
export interface BreakdownItem {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface VesselReportQuery {
|
||||
/** Bounds the time series and the "in period" figures only. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
granularity?: ReportGranularity;
|
||||
category?: VesselCategory[];
|
||||
status?: VesselStatus[];
|
||||
flagState?: string[];
|
||||
portOfRegistry?: string[];
|
||||
vesselType?: string[];
|
||||
search?: string;
|
||||
expiringWithinDays?: number;
|
||||
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
|
||||
topN?: number;
|
||||
tableLimit?: number;
|
||||
}
|
||||
|
||||
export interface RegisterKpis {
|
||||
total: number;
|
||||
registered: number;
|
||||
suspended: number;
|
||||
deregistered: number;
|
||||
registeredInPeriod: number;
|
||||
registeredInPreviousPeriod: number;
|
||||
/** Null when there is no previous period to compare against. */
|
||||
changePct: number | null;
|
||||
}
|
||||
|
||||
export interface FleetKpis {
|
||||
totalGrossTonnage: number;
|
||||
avgGrossTonnage: number | null;
|
||||
/** How many hulls the tonnage average actually covers. */
|
||||
grossTonnageKnownFor: number;
|
||||
totalPassengerCapacity: number;
|
||||
avgLengthMeters: number | null;
|
||||
avgAgeYears: number | null;
|
||||
ageKnownFor: number;
|
||||
seaGoing: number;
|
||||
inlandWaterway: number;
|
||||
}
|
||||
|
||||
export interface PipelineKpis {
|
||||
total: number;
|
||||
draft: number;
|
||||
inProgress: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
issued: number;
|
||||
submittedInPeriod: number;
|
||||
decidedInPeriod: number;
|
||||
newCount: number;
|
||||
renewalCount: number;
|
||||
/** Approved over settled. Null while nothing has been decided. */
|
||||
approvalRatePct: number | null;
|
||||
avgProcessingDays: number | null;
|
||||
medianProcessingDays: number | null;
|
||||
avgAdjustmentRounds: number | null;
|
||||
}
|
||||
|
||||
export interface CertificateKpis {
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
suspended: number;
|
||||
/** Cumulative: a certificate due in 11 days is inside all three. */
|
||||
expiringIn30: number;
|
||||
expiringIn60: number;
|
||||
expiringIn90: number;
|
||||
missingCertificate: number;
|
||||
}
|
||||
|
||||
export interface IncidentKpis {
|
||||
total: number;
|
||||
inPeriod: number;
|
||||
reportedByOfficer: number;
|
||||
reportedByOwner: number;
|
||||
vesselsWithIncidents: number;
|
||||
}
|
||||
|
||||
export interface RevenueKpis {
|
||||
currency: string;
|
||||
/** True when more than one currency was summed — warn rather than total. */
|
||||
mixedCurrency: boolean;
|
||||
paid: number;
|
||||
pending: number;
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
|
||||
export interface RegistrationBucket {
|
||||
bucket: string;
|
||||
count: number;
|
||||
grossTonnage: number;
|
||||
}
|
||||
|
||||
export interface ApplicationBucket {
|
||||
bucket: string;
|
||||
submitted: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
issued: number;
|
||||
}
|
||||
|
||||
export interface IncidentBucket {
|
||||
bucket: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RevenueBucket {
|
||||
bucket: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ExpiringCertificateRow {
|
||||
vesselId: string;
|
||||
registrationNumber: string;
|
||||
name: string;
|
||||
ownerName: string | null;
|
||||
ownerUserId: string;
|
||||
certificateNumber: string | null;
|
||||
expiryDate: string;
|
||||
certificateStatus: string | null;
|
||||
/** 0 means it expires today, which still counts as live. */
|
||||
daysToExpiry: number;
|
||||
}
|
||||
|
||||
export interface RecentRegistrationRow {
|
||||
vesselId: string;
|
||||
registrationNumber: string;
|
||||
name: string;
|
||||
category: VesselCategory;
|
||||
vesselType: string | null;
|
||||
flagState: string | null;
|
||||
grossTonnage: number | null;
|
||||
ownerName: string | null;
|
||||
status: VesselStatus;
|
||||
registeredAt: string;
|
||||
}
|
||||
|
||||
export interface RecentIncidentRow {
|
||||
id: string;
|
||||
vesselId: string;
|
||||
registrationNumber: string;
|
||||
vesselName: string;
|
||||
occurredAt: string;
|
||||
severity: string | null;
|
||||
location: string | null;
|
||||
description: string;
|
||||
reportedByOfficer: boolean;
|
||||
}
|
||||
|
||||
export interface PendingApplicationRow {
|
||||
applicationNumber: string;
|
||||
status: string;
|
||||
kind: 'NEW' | 'RENEWAL';
|
||||
assignedOfficerId: string | null;
|
||||
submittedAt: string | null;
|
||||
adjustmentRound: number;
|
||||
daysOpen: number;
|
||||
}
|
||||
|
||||
export interface VesselReport {
|
||||
generatedAt: string;
|
||||
/** True when the register passed the API's scan cap — figures are partial. */
|
||||
truncated: boolean;
|
||||
filters: {
|
||||
from: string;
|
||||
to: string;
|
||||
granularity: ReportGranularity;
|
||||
expiringWithinDays: number;
|
||||
topN: number;
|
||||
tableLimit: number;
|
||||
category: VesselCategory[] | null;
|
||||
status: VesselStatus[] | null;
|
||||
flagState: string[] | null;
|
||||
portOfRegistry: string[] | null;
|
||||
vesselType: string[] | null;
|
||||
search: string | null;
|
||||
};
|
||||
kpis: {
|
||||
register: RegisterKpis;
|
||||
fleet: FleetKpis;
|
||||
pipeline: PipelineKpis;
|
||||
certificates: CertificateKpis;
|
||||
incidents: IncidentKpis;
|
||||
revenue: RevenueKpis;
|
||||
};
|
||||
timeSeries: {
|
||||
registrations: RegistrationBucket[];
|
||||
applications: ApplicationBucket[];
|
||||
incidents: IncidentBucket[];
|
||||
revenue: RevenueBucket[];
|
||||
};
|
||||
breakdowns: {
|
||||
byStatus: BreakdownItem[];
|
||||
byCategory: BreakdownItem[];
|
||||
byFlagState: BreakdownItem[];
|
||||
byPortOfRegistry: BreakdownItem[];
|
||||
byVesselType: BreakdownItem[];
|
||||
byHullMaterial: BreakdownItem[];
|
||||
byEngineType: BreakdownItem[];
|
||||
byTonnageBand: BreakdownItem[];
|
||||
byLengthBand: BreakdownItem[];
|
||||
byAgeBand: BreakdownItem[];
|
||||
byBuildDecade: BreakdownItem[];
|
||||
byApplicationStatus: BreakdownItem[];
|
||||
byApplicationKind: BreakdownItem[];
|
||||
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
|
||||
byOfficer: BreakdownItem[];
|
||||
byIncidentSeverity: BreakdownItem[];
|
||||
};
|
||||
tables: {
|
||||
expiringCertificates: ExpiringCertificateRow[];
|
||||
recentRegistrations: RecentRegistrationRow[];
|
||||
recentIncidents: RecentIncidentRow[];
|
||||
pendingApplications: PendingApplicationRow[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const queryApi = baseApi.injectEndpoints({
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
export const { useApiQueryQuery, useLazyApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
|
||||
export function useApiQuery<TData = unknown>(
|
||||
args: ApiQueryArgs,
|
||||
@@ -37,6 +37,17 @@ export function useApiQuery<TData = unknown>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same endpoint as `useApiQuery`, fetched on demand instead of on render — for
|
||||
* the case where the arguments are only known at click time.
|
||||
*/
|
||||
export function useApiLazyQuery<TData = unknown>(): [
|
||||
(args: ApiQueryArgs) => { unwrap: () => Promise<TData> },
|
||||
] {
|
||||
const [trigger] = useLazyApiQueryQuery();
|
||||
return [trigger as unknown as (args: ApiQueryArgs) => { unwrap: () => Promise<TData> }];
|
||||
}
|
||||
|
||||
type UseApiMutationResult<TData> = {
|
||||
data: TData | undefined;
|
||||
isLoading: boolean;
|
||||
|
||||
Reference in New Issue
Block a user