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:
mihretue
2026-08-20 11:41:06 +00:00
189 changed files with 36273 additions and 4832 deletions

View File

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

View File

@@ -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?.();

View File

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

View File

@@ -1 +1 @@
export const tagTypes = ["ProfessionApi"];
export const tagTypes = ["ProfessionApi", "NumberFormatApi"];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './location.types';

View 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[];
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export * from './seafarer.types';
export * from './seafarer-api';
export * from './seafarer.helpers';

View File

@@ -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')],
}),

View 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];
}

View File

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

View File

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

View File

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

View File

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