mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'feature/seaman' of https://github.com/Tria-plc/emaui into certficate
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||
import { baseQueryWithReauth } from "./base-query-with-reauth";
|
||||
import { mockBaseQuery } from "./mock-base-query";
|
||||
import { tagTypes } from "./tagTypes";
|
||||
|
||||
// Dev-only preview mode: set VITE_USE_MOCKS=true (repo-root .env.local) to
|
||||
// serve the four preview feature areas from static sample data instead of a
|
||||
// live backend. See mock-base-query.ts for what is and isn't covered.
|
||||
const useMocks =
|
||||
(import.meta as { env?: Record<string, string> }).env?.["VITE_USE_MOCKS"] === "true";
|
||||
|
||||
export const baseApi = createApi({
|
||||
reducerPath: "baseApi",
|
||||
baseQuery: baseQueryWithReauth,
|
||||
baseQuery: useMocks ? mockBaseQuery : baseQueryWithReauth,
|
||||
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal file
385
libs/api/src/lib/base-api/mock-base-query.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import type { BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { baseQueryWithReauth } from './base-query-with-reauth';
|
||||
import {
|
||||
mockApplicationDetails,
|
||||
mockApplications,
|
||||
mockLicenses,
|
||||
mockLicenseTypeRequirements,
|
||||
mockLicenseTypes,
|
||||
mockMyExamAppeals,
|
||||
mockMyExamRegistrations,
|
||||
mockMyExamResults,
|
||||
mockNotifications,
|
||||
mockOpenExams,
|
||||
mockProfileMeResponse,
|
||||
mockVessels,
|
||||
} from './mock-data';
|
||||
|
||||
/**
|
||||
* Dev-only mock `BaseQueryFn`, opt-in via `VITE_USE_MOCKS=true` (see
|
||||
* `base-api/index.ts`).
|
||||
*
|
||||
* Design choice: this is a partial mock, not a full fake backend. Each
|
||||
* handler below matches a specific method + URL pattern for the four preview
|
||||
* feature areas (vessel registration, seafarer registration, CoC
|
||||
* certificates, exams) plus the shared profile/permissions/licensing-config
|
||||
* endpoints they depend on. Anything not matched here falls through to the
|
||||
* real `baseQueryWithReauth`, so login, signup, and any endpoint outside the
|
||||
* four areas keep talking to a real backend if one is reachable. This was
|
||||
* chosen over a full-mock/404 approach so the rest of the app (profile
|
||||
* editing, payments, other license types, backoffice) is unaffected and a
|
||||
* developer can extend coverage incrementally by adding more handlers below.
|
||||
*
|
||||
* Extend by adding another entry to `handlers`. Each handler receives the
|
||||
* parsed method/path/params/body and returns the response payload (or
|
||||
* `undefined` to fall through to the next handler / the real backend).
|
||||
*/
|
||||
|
||||
type MockRequest = {
|
||||
method: string;
|
||||
path: string;
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
type MockHandler = {
|
||||
method: string;
|
||||
/** Matches the URL path (query string stripped). */
|
||||
pattern: RegExp;
|
||||
respond: (req: MockRequest, match: RegExpMatchArray) => unknown;
|
||||
};
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function paginated<T>(items: T[]) {
|
||||
return { total: items.length, items };
|
||||
}
|
||||
|
||||
let mockAppSeq = 100;
|
||||
|
||||
const handlers: MockHandler[] = [
|
||||
// --------------------------------------------------------------- profile
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/profiles\/me$/,
|
||||
respond: () => clone(mockProfileMeResponse),
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------- license types
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/license-types$/,
|
||||
respond: () => paginated(clone(mockLicenseTypes)),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/license-types\/requirements\/([\w-]+)$/,
|
||||
respond: (_req, match) => {
|
||||
const key = match[1];
|
||||
const found = mockLicenseTypeRequirements[key];
|
||||
return found ? clone(found) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/license-categories$/,
|
||||
respond: () =>
|
||||
paginated([
|
||||
{ key: 'MARITIME_PERSONNEL', name: { en: 'Maritime Personnel' }, description: { en: 'Seafarer and vessel services' }, sortOrder: 1 },
|
||||
]),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/profiles\/me\/operations$/,
|
||||
respond: () => ({ items: [] }),
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------- applications
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/license-applications\/mine$/,
|
||||
respond: () => paginated(clone(Object.values(mockApplications))),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/license-applications$/,
|
||||
respond: (req) => {
|
||||
const body = (req.body ?? {}) as { licenseType?: string; kind?: string; previousLicenseId?: string };
|
||||
const typeKey = body.licenseType ?? 'VESSEL_REGISTRATION';
|
||||
const requirements = mockLicenseTypeRequirements[typeKey];
|
||||
const id = `app-draft-${++mockAppSeq}`;
|
||||
const now = new Date().toISOString();
|
||||
const application = {
|
||||
id,
|
||||
applicationNumber: `${requirements?.licenseType.certificatePrefix ?? 'APP'}-2026-${String(mockAppSeq).padStart(6, '0')}`,
|
||||
licenseTypeId: requirements?.licenseType.id ?? 'license-type-unknown',
|
||||
licenseType: requirements?.licenseType ?? { id: 'license-type-unknown', key: typeKey, name: { en: typeKey } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: body.kind ?? 'NEW',
|
||||
status: 'DRAFT',
|
||||
assignedOfficerId: null,
|
||||
claimedAt: null,
|
||||
formData: {},
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: null,
|
||||
decidedAt: null,
|
||||
rejectionReason: null,
|
||||
feeAmount: String(requirements?.fee ?? ''),
|
||||
feeCurrency: requirements?.feeCurrency ?? 'ETB',
|
||||
issuedLicenseId: null,
|
||||
createdAt: now,
|
||||
};
|
||||
mockApplications[id] = application;
|
||||
mockApplicationDetails[id] = {
|
||||
application,
|
||||
staff: [],
|
||||
attachments: [],
|
||||
history: [
|
||||
{
|
||||
id: `hist-${id}-1`,
|
||||
fromStatus: null,
|
||||
toStatus: 'DRAFT',
|
||||
event: 'created',
|
||||
actorUserId: 'user-mock-001',
|
||||
actorName: 'Abebe Tesfaye',
|
||||
actorRole: 'APPLICANT',
|
||||
remark: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
remarks: [],
|
||||
openRemarks: [],
|
||||
availableEvents: [],
|
||||
};
|
||||
return clone(application);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/license-applications\/([\w-]+)$/,
|
||||
respond: (_req, match) => {
|
||||
const detail = mockApplicationDetails[match[1]];
|
||||
return detail ? clone(detail) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,
|
||||
respond: (req, match) => {
|
||||
const [, id, sectionKey] = match;
|
||||
const application = mockApplications[id];
|
||||
if (!application) return undefined;
|
||||
const body = (req.body ?? {}) as { values?: Record<string, unknown> };
|
||||
application.formData = { ...application.formData, [sectionKey]: body.values ?? {} };
|
||||
return clone(application);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/submit$/,
|
||||
respond: (_req, match) => {
|
||||
const application = mockApplications[match[1]];
|
||||
if (!application) return undefined;
|
||||
application.status = 'UNDER_REVIEW';
|
||||
application.submittedAt = new Date().toISOString();
|
||||
return clone(application);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/resubmit$/,
|
||||
respond: (_req, match) => {
|
||||
const application = mockApplications[match[1]];
|
||||
if (!application) return undefined;
|
||||
application.status = 'UNDER_REVIEW';
|
||||
return clone(application);
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/remarks\/([\w-]+)\/resolve$/,
|
||||
respond: () => ({ ok: true }),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/staff$/,
|
||||
respond: (req) => {
|
||||
const body = (req.body ?? {}) as { roleKey?: string; fullName?: string; position?: string; yearsOfExperience?: number };
|
||||
return {
|
||||
id: `staff-mock-${Date.now()}`,
|
||||
roleKey: body.roleKey ?? '',
|
||||
fullName: body.fullName ?? '',
|
||||
position: body.position ?? null,
|
||||
roleCategory: null,
|
||||
yearsOfExperience: body.yearsOfExperience ?? null,
|
||||
documents: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pattern: /^\/license-applications\/([\w-]+)\/staff\/([\w-]+)$/,
|
||||
respond: () => ({ ok: true }),
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- licenses
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/licenses\/mine$/,
|
||||
respond: () => paginated(clone(mockLicenses)),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/licenses\/([\w-]+)\/certificate$/,
|
||||
respond: (_req, match) => ({
|
||||
url: `https://example-cdn.ema.gov.et/certificates/${match[1]}.pdf`,
|
||||
}),
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------ attachments
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/attachments$/,
|
||||
respond: () => [],
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------- notifications
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/notifications\/unseen$/,
|
||||
respond: () => clone(mockNotifications),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/notifications$/,
|
||||
respond: () => clone(mockNotifications),
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
pattern: /^\/notifications\/([\w-]+)\/read$/,
|
||||
respond: () => ({ ok: true }),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------- vessels
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/vessels\/mine$/,
|
||||
respond: () => clone(mockVessels),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/vessels\/([\w-]+)\/incidents$/,
|
||||
respond: (req, match) => ({
|
||||
id: `incident-mock-${Date.now()}`,
|
||||
vesselId: match[1],
|
||||
occurredAt: (req.body as { occurredAt?: string })?.occurredAt ?? new Date().toISOString(),
|
||||
location: (req.body as { location?: string })?.location ?? null,
|
||||
description: (req.body as { description?: string })?.description ?? '',
|
||||
severity: null,
|
||||
reportedById: 'user-mock-001',
|
||||
reportedByOfficer: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------ exams
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/exams\/open$/,
|
||||
respond: () => clone(mockOpenExams),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/exams\/registrations\/mine$/,
|
||||
respond: () => clone(mockMyExamRegistrations),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/exams\/([\w-]+)\/register$/,
|
||||
respond: () => ({ admissionNumber: `ADM-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/results\/mine$/,
|
||||
respond: () => clone(mockMyExamResults),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/results\/appeals\/mine$/,
|
||||
respond: () => clone(mockMyExamAppeals),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/results\/([\w-]+)\/appeal$/,
|
||||
respond: () => ({ appealNumber: `APL-2026-${Math.floor(Math.random() * 900000 + 100000)}` }),
|
||||
},
|
||||
|
||||
// -------------------------------------------------------- seafarer records
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/sea-service-records\/mine$/,
|
||||
respond: () => [],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/medical-certificates\/mine$/,
|
||||
respond: () => [],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/sea-service-records\/mine\/sea-time$/,
|
||||
respond: () => ({ totalDays: 620, verifiedRecords: 4 }),
|
||||
},
|
||||
];
|
||||
|
||||
function parseArgs(args: string | FetchArgs): { method: string; path: string; params?: Record<string, unknown>; body?: unknown } {
|
||||
if (typeof args === 'string') {
|
||||
const [path] = args.split('?');
|
||||
return { method: 'GET', path };
|
||||
}
|
||||
const [path] = (args.url ?? '').split('?');
|
||||
return {
|
||||
method: (args.method ?? 'GET').toUpperCase(),
|
||||
path,
|
||||
params: args.params as Record<string, unknown> | undefined,
|
||||
body: args.body,
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const mockBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
|
||||
args,
|
||||
api,
|
||||
extraOptions,
|
||||
) => {
|
||||
const req = parseArgs(args);
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (handler.method !== req.method) continue;
|
||||
const match = req.path.match(handler.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
const data = handler.respond(req, match);
|
||||
if (data === undefined) continue;
|
||||
|
||||
await delay(150 + Math.floor(Math.random() * 150));
|
||||
return { data };
|
||||
}
|
||||
|
||||
// Unmatched — fall through to the real backend (auth/login, and any
|
||||
// endpoint outside the four mocked preview areas).
|
||||
return baseQueryWithReauth(args, api, extraOptions);
|
||||
};
|
||||
740
libs/api/src/lib/base-api/mock-data.ts
Normal file
740
libs/api/src/lib/base-api/mock-data.ts
Normal file
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* Dev-only sample data for the mock base query (see `mock-base-query.ts`).
|
||||
*
|
||||
* Throwaway preview fixtures — realistic enough to demo the four portal
|
||||
* feature areas (vessel registration, seafarer registration, CoC
|
||||
* certificates, exams) without a live backend. Not used unless
|
||||
* `VITE_USE_MOCKS=true`.
|
||||
*/
|
||||
|
||||
// ------------------------------------------------------------------ profile
|
||||
|
||||
export const mockPermissions: string[] = [
|
||||
'can:View:own-profile',
|
||||
'can:edit:own-profile',
|
||||
'can:resubmit:license-application',
|
||||
'can:upload:own-documents',
|
||||
'can:View:own-documents',
|
||||
'can:replace:own-documents',
|
||||
'can:initiate:own-payment',
|
||||
'can:View:own-payments',
|
||||
'can:View:own-notifications',
|
||||
'can:apply:seafarer-registration',
|
||||
'can:add:own-sea-service',
|
||||
'can:edit:own-sea-service',
|
||||
'can:View:own-sea-service',
|
||||
'can:upload:own-medical-certificate',
|
||||
'can:View:own-medical-certificate',
|
||||
'can:apply:seafarer-certificate',
|
||||
'can:apply:exam',
|
||||
'can:View:own-exam',
|
||||
'can:View:own-certificates',
|
||||
'can:apply:vessel-registration',
|
||||
'can:View:own-vessels',
|
||||
'can:report:own-vessel-incident',
|
||||
'can:create:license-application',
|
||||
'can:View:my-license-applications',
|
||||
'can:update:license-application',
|
||||
'can:submit:license-application',
|
||||
];
|
||||
|
||||
export const mockProfile = {
|
||||
id: 'profile-mock-001',
|
||||
userId: 'user-mock-001',
|
||||
professionId: 'profession-mock-001',
|
||||
addressId: 'address-mock-001',
|
||||
type: 'INDIVIDUAL',
|
||||
firstName: 'Abebe',
|
||||
middleName: 'Kebede',
|
||||
lastName: 'Tesfaye',
|
||||
gender: 'MALE',
|
||||
dob: '1990-04-12',
|
||||
pob: 'Addis Ababa',
|
||||
maritalStatus: 'MARRIED',
|
||||
isComplete: true,
|
||||
seafarerNumber: 'ET-SF-2026-00147',
|
||||
seafarerStatus: 'ACTIVE',
|
||||
seafarerDepartment: 'DECK',
|
||||
seafarerStatusReason: null,
|
||||
user: {
|
||||
id: 'user-mock-001',
|
||||
email: 'abebe.tesfaye@example.et',
|
||||
username: 'abebe.tesfaye',
|
||||
phoneNumber: '+251911223344',
|
||||
name: { am: 'አበበ ተስፋዬ', en: 'Abebe Tesfaye' },
|
||||
status: 'ACTIVE',
|
||||
sharepointId: null,
|
||||
hasSetPassword: true,
|
||||
hasFinishedRegistration: true,
|
||||
hasFinishedDMSOnboarding: true,
|
||||
isPhoneNumberVerified: true,
|
||||
userType: 'PORTAL',
|
||||
},
|
||||
address: {
|
||||
id: 'address-mock-001',
|
||||
idType: 'NID',
|
||||
idNumber: '1234567890123',
|
||||
nationality: 'Ethiopia',
|
||||
regionId: 'region-addis',
|
||||
cityId: 'city-addis',
|
||||
subCityId: 'subcity-bole',
|
||||
woredaId: 'woreda-03',
|
||||
kebeleId: null,
|
||||
streetAddress: 'Bole Road',
|
||||
houseNumber: '14',
|
||||
primaryPhoneNumber: '+251911223344',
|
||||
secondaryPhoneNumber: null,
|
||||
email: 'abebe.tesfaye@example.et',
|
||||
website: null,
|
||||
postalAddress: null,
|
||||
emergencyContactName: 'Selamawit Tesfaye',
|
||||
emergencyContactPhone: '+251911998877',
|
||||
emergencyContactRelation: 'Spouse',
|
||||
isActive: true,
|
||||
},
|
||||
profession: {
|
||||
id: 'profession-mock-001',
|
||||
departmentId: 'department-deck',
|
||||
name: { en: 'Deck Officer' },
|
||||
description: { en: 'Deck department officer' },
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockProfileMeResponse = {
|
||||
profile: mockProfile,
|
||||
completeness: 100,
|
||||
missing: [] as string[],
|
||||
permissions: mockPermissions,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ vessels
|
||||
|
||||
export const mockVessels = [
|
||||
{
|
||||
id: 'vessel-mock-001',
|
||||
registrationNumber: 'ET-VES-2025-000041',
|
||||
name: 'MV Abay Queen',
|
||||
category: 'INLAND_WATERWAY',
|
||||
vesselType: 'Passenger Ferry',
|
||||
imoNumber: '9876543',
|
||||
hullNumber: 'HN-4471',
|
||||
flagState: 'Ethiopia',
|
||||
portOfRegistry: 'Bahir Dar',
|
||||
grossTonnage: 420,
|
||||
passengerCapacity: 120,
|
||||
lengthMeters: 38.5,
|
||||
yearBuilt: 2018,
|
||||
engineType: 'Diesel',
|
||||
enginePowerKw: 950,
|
||||
numberOfEngines: 2,
|
||||
hullMaterial: 'Steel',
|
||||
ownerUserId: 'user-mock-001',
|
||||
ownerProfileId: 'profile-mock-001',
|
||||
ownerName: 'Abebe Tesfaye',
|
||||
applicationId: 'app-vessel-approved-001',
|
||||
licenseId: 'license-vessel-001',
|
||||
status: 'REGISTERED',
|
||||
statusReason: null,
|
||||
statusChangedAt: null,
|
||||
registeredAt: '2025-11-03T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'vessel-mock-002',
|
||||
registrationNumber: 'ET-VES-2024-000019',
|
||||
name: 'MV Tana Star',
|
||||
category: 'INLAND_WATERWAY',
|
||||
vesselType: 'Cargo Vessel',
|
||||
imoNumber: '9765432',
|
||||
hullNumber: 'HN-2290',
|
||||
flagState: 'Ethiopia',
|
||||
portOfRegistry: 'Bahir Dar',
|
||||
grossTonnage: 610,
|
||||
passengerCapacity: null,
|
||||
lengthMeters: 45.2,
|
||||
yearBuilt: 2015,
|
||||
engineType: 'Diesel',
|
||||
enginePowerKw: 1200,
|
||||
numberOfEngines: 2,
|
||||
hullMaterial: 'Steel',
|
||||
ownerUserId: 'user-mock-001',
|
||||
ownerProfileId: 'profile-mock-001',
|
||||
ownerName: 'Abebe Tesfaye',
|
||||
applicationId: 'app-vessel-approved-002',
|
||||
licenseId: 'license-vessel-002',
|
||||
status: 'REGISTERED',
|
||||
statusReason: null,
|
||||
statusChangedAt: null,
|
||||
registeredAt: '2024-06-18T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------- applications
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
export const mockApplications: Record<string, any> = {
|
||||
'app-vessel-approved-001': {
|
||||
id: 'app-vessel-approved-001',
|
||||
applicationNumber: 'VES-2025-000041',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'CERTIFICATE_ISSUED',
|
||||
assignedOfficerId: null,
|
||||
claimedAt: null,
|
||||
formData: { vesselDetails: { vesselName: 'MV Abay Queen' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2025-10-20T08:00:00.000Z',
|
||||
decidedAt: '2025-11-03T09:00:00.000Z',
|
||||
rejectionReason: null,
|
||||
feeAmount: '3500',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: 'license-vessel-001',
|
||||
createdAt: '2025-10-15T08:00:00.000Z',
|
||||
},
|
||||
'app-vessel-approved-002': {
|
||||
id: 'app-vessel-approved-002',
|
||||
applicationNumber: 'VES-2024-000019',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'CERTIFICATE_ISSUED',
|
||||
assignedOfficerId: null,
|
||||
claimedAt: null,
|
||||
formData: { vesselDetails: { vesselName: 'MV Tana Star' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2024-06-01T08:00:00.000Z',
|
||||
decidedAt: '2024-06-18T09:00:00.000Z',
|
||||
rejectionReason: null,
|
||||
feeAmount: '3500',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: 'license-vessel-002',
|
||||
createdAt: '2024-05-20T08:00:00.000Z',
|
||||
},
|
||||
'app-vessel-pending-003': {
|
||||
id: 'app-vessel-pending-003',
|
||||
applicationNumber: 'VES-2026-000123',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'UNDER_REVIEW',
|
||||
assignedOfficerId: 'officer-mock-001',
|
||||
claimedAt: '2026-08-10T10:00:00.000Z',
|
||||
formData: { vesselDetails: { vesselName: 'MV Zeway Pearl' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2026-08-05T08:00:00.000Z',
|
||||
decidedAt: null,
|
||||
rejectionReason: null,
|
||||
feeAmount: '3500',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: null,
|
||||
createdAt: '2026-08-01T08:00:00.000Z',
|
||||
},
|
||||
'app-vessel-resubmit-004': {
|
||||
id: 'app-vessel-resubmit-004',
|
||||
applicationNumber: 'VES-2026-000098',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'RESUBMIT_REQUIRED',
|
||||
assignedOfficerId: 'officer-mock-001',
|
||||
claimedAt: '2026-07-20T10:00:00.000Z',
|
||||
formData: { vesselDetails: { vesselName: 'MV Koka Voyager' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 1,
|
||||
submittedAt: '2026-07-15T08:00:00.000Z',
|
||||
decidedAt: null,
|
||||
rejectionReason: null,
|
||||
feeAmount: '3500',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: null,
|
||||
createdAt: '2026-07-10T08:00:00.000Z',
|
||||
},
|
||||
'app-seafarer-registered-005': {
|
||||
id: 'app-seafarer-registered-005',
|
||||
applicationNumber: 'SEA-2025-000512',
|
||||
licenseTypeId: 'license-type-seafarer-registration',
|
||||
licenseType: { id: 'license-type-seafarer-registration', key: 'SEAFARER_REGISTRATION', name: { en: 'Seafarer Registration' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'COMPLETED',
|
||||
assignedOfficerId: null,
|
||||
claimedAt: null,
|
||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2025-09-01T08:00:00.000Z',
|
||||
decidedAt: '2025-09-20T08:00:00.000Z',
|
||||
rejectionReason: null,
|
||||
feeAmount: '500',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: null,
|
||||
createdAt: '2025-08-25T08:00:00.000Z',
|
||||
},
|
||||
'app-coc-issued-006': {
|
||||
id: 'app-coc-issued-006',
|
||||
applicationNumber: 'COC-2025-000321',
|
||||
licenseTypeId: 'license-type-coc',
|
||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'NEW',
|
||||
status: 'CERTIFICATE_ISSUED',
|
||||
assignedOfficerId: null,
|
||||
claimedAt: null,
|
||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2025-12-01T08:00:00.000Z',
|
||||
decidedAt: '2026-01-10T08:00:00.000Z',
|
||||
rejectionReason: null,
|
||||
feeAmount: '1200',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: 'license-coc-001',
|
||||
createdAt: '2025-11-20T08:00:00.000Z',
|
||||
},
|
||||
'app-coc-review-007': {
|
||||
id: 'app-coc-review-007',
|
||||
applicationNumber: 'COC-2026-000045',
|
||||
licenseTypeId: 'license-type-coc',
|
||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||
applicantUserId: 'user-mock-001',
|
||||
kind: 'RENEWAL',
|
||||
status: 'ELIGIBILITY_APPROVED',
|
||||
assignedOfficerId: 'officer-mock-002',
|
||||
claimedAt: '2026-08-01T10:00:00.000Z',
|
||||
formData: { account: { applicantName: 'Abebe Tesfaye' } },
|
||||
companyName: null,
|
||||
tradeName: null,
|
||||
tinNumber: null,
|
||||
businessAddress: null,
|
||||
capitalAmountDeclared: null,
|
||||
capitalAmountVerified: null,
|
||||
adjustmentRound: 0,
|
||||
submittedAt: '2026-07-28T08:00:00.000Z',
|
||||
decidedAt: null,
|
||||
rejectionReason: null,
|
||||
feeAmount: '1200',
|
||||
feeCurrency: 'ETB',
|
||||
issuedLicenseId: null,
|
||||
createdAt: '2026-07-25T08:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
export const mockApplicationDetails: Record<string, any> = Object.fromEntries(
|
||||
Object.entries(mockApplications).map(([id, application]) => [
|
||||
id,
|
||||
{
|
||||
application,
|
||||
staff: [],
|
||||
attachments: [],
|
||||
history: [
|
||||
{
|
||||
id: `hist-${id}-1`,
|
||||
fromStatus: null,
|
||||
toStatus: 'DRAFT',
|
||||
event: 'created',
|
||||
actorUserId: 'user-mock-001',
|
||||
actorName: 'Abebe Tesfaye',
|
||||
actorRole: 'APPLICANT',
|
||||
remark: null,
|
||||
metadata: null,
|
||||
createdAt: application.createdAt,
|
||||
},
|
||||
{
|
||||
id: `hist-${id}-2`,
|
||||
fromStatus: 'DRAFT',
|
||||
toStatus: 'SUBMITTED',
|
||||
event: 'submitted',
|
||||
actorUserId: 'user-mock-001',
|
||||
actorName: 'Abebe Tesfaye',
|
||||
actorRole: 'APPLICANT',
|
||||
remark: null,
|
||||
metadata: null,
|
||||
createdAt: application.submittedAt ?? application.createdAt,
|
||||
},
|
||||
],
|
||||
remarks:
|
||||
application.status === 'RESUBMIT_REQUIRED'
|
||||
? [
|
||||
{
|
||||
id: `remark-${id}-1`,
|
||||
roundNumber: 1,
|
||||
targetType: 'DOCUMENT',
|
||||
targetKey: 'vesselOwnershipProof',
|
||||
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||
isResolved: false,
|
||||
resolvedAt: null,
|
||||
createdAt: application.submittedAt ?? application.createdAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
openRemarks:
|
||||
application.status === 'RESUBMIT_REQUIRED'
|
||||
? [
|
||||
{
|
||||
id: `remark-${id}-1`,
|
||||
roundNumber: 1,
|
||||
targetType: 'DOCUMENT',
|
||||
targetKey: 'vesselOwnershipProof',
|
||||
remark: 'Ownership document is blurred — please re-upload a clearer scan.',
|
||||
isResolved: false,
|
||||
resolvedAt: null,
|
||||
createdAt: application.submittedAt ?? application.createdAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
availableEvents: [],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- licenses
|
||||
|
||||
export const mockLicenses = [
|
||||
{
|
||||
id: 'license-vessel-001',
|
||||
certificateNumber: 'ET-VES-CERT-2025-041',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicationId: 'app-vessel-approved-001',
|
||||
companyName: null,
|
||||
tinNumber: null,
|
||||
issueDate: '2025-11-03',
|
||||
expiryDate: '2027-11-03',
|
||||
status: 'ACTIVE',
|
||||
daysUntilExpiry: 445,
|
||||
renewable: false,
|
||||
verificationCode: 'VESQR001',
|
||||
certificateFileKey: 'certs/vessel-001.pdf',
|
||||
},
|
||||
{
|
||||
id: 'license-vessel-002',
|
||||
certificateNumber: 'ET-VES-CERT-2024-019',
|
||||
licenseTypeId: 'license-type-vessel-registration',
|
||||
licenseType: { id: 'license-type-vessel-registration', key: 'VESSEL_REGISTRATION', name: { en: 'Vessel Registration' } },
|
||||
applicationId: 'app-vessel-approved-002',
|
||||
companyName: null,
|
||||
tinNumber: null,
|
||||
issueDate: '2024-06-18',
|
||||
expiryDate: '2026-06-18',
|
||||
status: 'ACTIVE',
|
||||
daysUntilExpiry: 308,
|
||||
renewable: true,
|
||||
verificationCode: 'VESQR002',
|
||||
certificateFileKey: 'certs/vessel-002.pdf',
|
||||
},
|
||||
{
|
||||
id: 'license-coc-001',
|
||||
certificateNumber: 'ET-COC-2026-000321',
|
||||
licenseTypeId: 'license-type-coc',
|
||||
licenseType: { id: 'license-type-coc', key: 'CERTIFICATE_OF_COMPETENCY', name: { en: 'Certificate of Competency' } },
|
||||
applicationId: 'app-coc-issued-006',
|
||||
companyName: null,
|
||||
tinNumber: null,
|
||||
issueDate: '2026-01-10',
|
||||
expiryDate: '2031-01-10',
|
||||
status: 'ACTIVE',
|
||||
daysUntilExpiry: 1610,
|
||||
renewable: false,
|
||||
verificationCode: 'COCQR001',
|
||||
certificateFileKey: 'certs/coc-001.pdf',
|
||||
},
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------- exams
|
||||
|
||||
export const mockOpenExams = [
|
||||
{
|
||||
id: 'exam-open-001',
|
||||
title: { en: 'Officer of the Watch — Written Examination' },
|
||||
date: '2026-09-15T09:00:00.000Z',
|
||||
venue: 'EMA Headquarters, Addis Ababa',
|
||||
status: 'OPEN',
|
||||
certification: { name: { en: 'Certificate of Competency (Deck)' } },
|
||||
},
|
||||
{
|
||||
id: 'exam-open-002',
|
||||
title: { en: 'Marine Engineering Practical Assessment' },
|
||||
date: '2026-10-02T09:00:00.000Z',
|
||||
venue: 'Bahir Dar Maritime Training Center',
|
||||
status: 'OPEN',
|
||||
certification: { name: { en: 'Certificate of Competency (Engine)' } },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMyExamRegistrations = [
|
||||
{
|
||||
id: 'reg-mock-001',
|
||||
admissionNumber: 'ADM-2026-000876',
|
||||
createdAt: '2026-08-01T08:00:00.000Z',
|
||||
kind: 'NEW',
|
||||
attemptNumber: 1,
|
||||
attendanceStatus: 'REGISTERED',
|
||||
exam: mockOpenExams[0],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMyExamResults = [
|
||||
{
|
||||
id: 'result-mock-001',
|
||||
totalScore: 82,
|
||||
status: 'PASSED',
|
||||
publishedAt: '2026-03-20T08:00:00.000Z',
|
||||
remark: { en: 'Strong performance in navigation and safety.' },
|
||||
exam: {
|
||||
id: 'exam-past-001',
|
||||
title: { en: 'Officer of the Watch — Written Examination' },
|
||||
date: '2026-03-10T09:00:00.000Z',
|
||||
venue: 'EMA Headquarters, Addis Ababa',
|
||||
status: 'CLOSED',
|
||||
certification: { name: { en: 'Certificate of Competency (Deck)' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMyExamAppeals: any[] = [];
|
||||
|
||||
// ------------------------------------------------------------ notifications
|
||||
|
||||
export const mockNotifications = {
|
||||
count: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'notif-mock-001',
|
||||
subject: { en: 'Application under review' },
|
||||
content: { en: 'Your vessel registration VES-2026-000123 is now under review.' },
|
||||
isSeen: false,
|
||||
itemId: 'app-vessel-pending-003',
|
||||
itemType: 'LicenseApplication',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-05T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'notif-mock-002',
|
||||
subject: { en: 'Corrections requested' },
|
||||
content: { en: 'Please resubmit documents for VES-2026-000098.' },
|
||||
isSeen: true,
|
||||
itemId: 'app-vessel-resubmit-004',
|
||||
itemType: 'LicenseApplication',
|
||||
metadata: null,
|
||||
createdAt: '2026-07-21T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ license types
|
||||
|
||||
export const mockLicenseTypeRequirements: Record<string, any> = {
|
||||
VESSEL_REGISTRATION: {
|
||||
licenseType: {
|
||||
id: 'license-type-vessel-registration',
|
||||
key: 'VESSEL_REGISTRATION',
|
||||
name: { en: 'Vessel Registration' },
|
||||
description: { en: 'Register a vessel with the Ethiopian Maritime Authority.' },
|
||||
category: 'MARITIME_PERSONNEL',
|
||||
certificatePrefix: 'VES',
|
||||
feeNewApplication: 3500,
|
||||
feeRenewal: 2000,
|
||||
feeCurrency: 'ETB',
|
||||
capitalThreshold: null,
|
||||
validityMonths: 24,
|
||||
slaHours: 240,
|
||||
inspectionRequired: true,
|
||||
issuesCertificate: true,
|
||||
renewalEnabled: true,
|
||||
requiresOperatorMode: false,
|
||||
formSchema: {
|
||||
sections: [
|
||||
{
|
||||
key: 'vesselDetails',
|
||||
title: { en: 'Vessel Details' },
|
||||
fields: [
|
||||
{ key: 'vesselName', label: { en: 'Vessel Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||
{ key: 'vesselType', label: { en: 'Vessel Type' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||
{ key: 'grossTonnage', label: { en: 'Gross Tonnage' }, type: 'NUMBER', required: true, sortOrder: 3 },
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
key: 'ownerDetails',
|
||||
title: { en: 'Owner Details' },
|
||||
fields: [
|
||||
{ key: 'nationality', label: { en: 'Nationality' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||
{ key: 'idNumber', label: { en: 'National ID (Fayda) Number' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||
],
|
||||
sortOrder: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
applicationKind: 'NEW',
|
||||
fee: 3500,
|
||||
feeCurrency: 'ETB',
|
||||
documentRequirements: [
|
||||
{
|
||||
id: 'doc-vessel-ownership',
|
||||
key: 'vesselOwnershipProof',
|
||||
name: { en: 'Proof of Ownership' },
|
||||
applicationKind: 'NEW',
|
||||
mode: 'ALWAYS',
|
||||
allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
|
||||
maxSizeMb: 10,
|
||||
requiresValidityDates: false,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
staffRoleRequirements: [],
|
||||
},
|
||||
SEAFARER_REGISTRATION: {
|
||||
licenseType: {
|
||||
id: 'license-type-seafarer-registration',
|
||||
key: 'SEAFARER_REGISTRATION',
|
||||
name: { en: 'Seafarer Registration' },
|
||||
description: { en: 'Register as a seafarer with the Ethiopian Maritime Authority.' },
|
||||
category: 'MARITIME_PERSONNEL',
|
||||
certificatePrefix: 'SEA',
|
||||
feeNewApplication: 500,
|
||||
feeRenewal: null,
|
||||
feeCurrency: 'ETB',
|
||||
capitalThreshold: null,
|
||||
validityMonths: 0,
|
||||
slaHours: 120,
|
||||
inspectionRequired: false,
|
||||
issuesCertificate: false,
|
||||
renewalEnabled: false,
|
||||
requiresOperatorMode: false,
|
||||
formSchema: {
|
||||
sections: [
|
||||
{
|
||||
key: 'personalDetails',
|
||||
title: { en: 'Personal Details' },
|
||||
fields: [
|
||||
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||
{ key: 'department', label: { en: 'Department' }, type: 'SELECT', required: true, options: [
|
||||
{ value: 'DECK', label: { en: 'Deck' } },
|
||||
{ value: 'ENGINE', label: { en: 'Engine' } },
|
||||
{ value: 'CATERING', label: { en: 'Catering' } },
|
||||
], sortOrder: 2 },
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
isActive: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
applicationKind: 'NEW',
|
||||
fee: 500,
|
||||
feeCurrency: 'ETB',
|
||||
documentRequirements: [
|
||||
{
|
||||
id: 'doc-seafarer-photo',
|
||||
key: 'passportPhoto',
|
||||
name: { en: 'Passport-size Photograph' },
|
||||
applicationKind: 'NEW',
|
||||
mode: 'ALWAYS',
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png'],
|
||||
maxSizeMb: 5,
|
||||
requiresValidityDates: false,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
staffRoleRequirements: [],
|
||||
},
|
||||
CERTIFICATE_OF_COMPETENCY: {
|
||||
licenseType: {
|
||||
id: 'license-type-coc',
|
||||
key: 'CERTIFICATE_OF_COMPETENCY',
|
||||
name: { en: 'Certificate of Competency' },
|
||||
description: { en: 'Apply for a Certificate of Competency.' },
|
||||
category: 'MARITIME_PERSONNEL',
|
||||
certificatePrefix: 'COC',
|
||||
feeNewApplication: 1200,
|
||||
feeRenewal: 800,
|
||||
feeCurrency: 'ETB',
|
||||
capitalThreshold: null,
|
||||
validityMonths: 60,
|
||||
slaHours: 168,
|
||||
inspectionRequired: false,
|
||||
issuesCertificate: true,
|
||||
renewalEnabled: true,
|
||||
requiresOperatorMode: false,
|
||||
formSchema: {
|
||||
sections: [
|
||||
{
|
||||
key: 'applicantDetails',
|
||||
title: { en: 'Applicant Details' },
|
||||
fields: [
|
||||
{ key: 'applicantName', label: { en: 'Full Name' }, type: 'TEXT', required: true, sortOrder: 1 },
|
||||
{ key: 'rank', label: { en: 'Rank Applied For' }, type: 'TEXT', required: true, sortOrder: 2 },
|
||||
],
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
isActive: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
applicationKind: 'NEW',
|
||||
fee: 1200,
|
||||
feeCurrency: 'ETB',
|
||||
documentRequirements: [
|
||||
{
|
||||
id: 'doc-coc-seatime',
|
||||
key: 'seaTimeRecord',
|
||||
name: { en: 'Sea Time Record' },
|
||||
applicationKind: 'NEW',
|
||||
mode: 'ALWAYS',
|
||||
allowedMimeTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
requiresValidityDates: false,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
staffRoleRequirements: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockLicenseTypes = Object.values(mockLicenseTypeRequirements).map(
|
||||
(r) => r.licenseType,
|
||||
);
|
||||
@@ -485,6 +485,33 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/** Places a candidate who has paid the examination fee into a sitting. */
|
||||
scheduleExam: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; examId: string; admissionNumber?: string; examDate?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/schedule-exam`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Raises the examination fee — after eligibility approval, or again when
|
||||
* a failed candidate elects to resit.
|
||||
*/
|
||||
requestExamPayment: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({
|
||||
url: `/license-applications/${id}/request-exam-payment`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------- certificate designs
|
||||
getLicenseTemplates: builder.query<LicenseTemplate[], string | void>({
|
||||
query: (licenseTypeId) => ({
|
||||
@@ -522,6 +549,7 @@ export const licensingApi = baseApi
|
||||
name?: string;
|
||||
hbsSource?: string;
|
||||
pageOptions?: TemplatePageOptions;
|
||||
backgroundUrl?: string;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
@@ -805,6 +833,8 @@ export const {
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useScheduleExamMutation,
|
||||
useRequestExamPaymentMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
|
||||
@@ -70,6 +70,12 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
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. */
|
||||
@@ -89,6 +95,13 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
PAYMENT_CONFIRMED: 'teal',
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +127,15 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
CERTIFICATE_ISSUED: 100,
|
||||
COMPLETED: 100,
|
||||
REJECTED: 100,
|
||||
// The exam leg sits between approval and the certificate fee, so these
|
||||
// interleave with PAYMENT_PENDING (80) rather than running past it.
|
||||
ELIGIBILITY_APPROVED: 60,
|
||||
EXAM_PAYMENT_PENDING: 64,
|
||||
EXAM_PAID: 68,
|
||||
EXAM_SCHEDULED: 72,
|
||||
EXAM_PASSED: 78,
|
||||
// A resit returns to the fee step, so this is not further along than a pass.
|
||||
EXAM_FAILED: 64,
|
||||
};
|
||||
|
||||
/** Statuses where nothing moves until the applicant does something. */
|
||||
@@ -121,6 +143,10 @@ export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
'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. */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/** Shared licensing contract — mirrors the emaapi domain model. */
|
||||
|
||||
// Reused rather than redeclared: the department vocabulary belongs to the
|
||||
// seafarer domain, and two copies would drift.
|
||||
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
|
||||
|
||||
export type Bilingual = { en?: string; am?: string };
|
||||
|
||||
/**
|
||||
@@ -21,7 +25,15 @@ export type LicenseStatus =
|
||||
| "PAID"
|
||||
| "PAYMENT_CONFIRMED"
|
||||
| "CERTIFICATE_ISSUED"
|
||||
| "COMPLETED";
|
||||
| "COMPLETED"
|
||||
// Examined certificates (CoC, some CoP): approval establishes eligibility,
|
||||
// the candidate pays to sit, and the certificate fee falls due on a pass.
|
||||
| "ELIGIBILITY_APPROVED"
|
||||
| "EXAM_PAYMENT_PENDING"
|
||||
| "EXAM_PAID"
|
||||
| "EXAM_SCHEDULED"
|
||||
| "EXAM_PASSED"
|
||||
| "EXAM_FAILED";
|
||||
|
||||
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||
|
||||
@@ -130,6 +142,58 @@ export interface LicenseType {
|
||||
isActive: boolean;
|
||||
/** Display order set by EMA; lower comes first. */
|
||||
sortOrder: number;
|
||||
|
||||
// --------------------------------------------------- examined certificates
|
||||
/** Approval establishes eligibility; the certificate is earned by exam. */
|
||||
requiresExamination?: boolean;
|
||||
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
|
||||
feeExamination?: string | number | null;
|
||||
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
|
||||
feeCertificate?: string | number | null;
|
||||
|
||||
// ---------------------------------------------------------- STCW mapping
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
stcwControlled?: boolean;
|
||||
/** Convention regulation, e.g. `III/2`. */
|
||||
stcwRegulation?: string | null;
|
||||
/** Code section, e.g. `A-III/2`. */
|
||||
stcwCodeSection?: string | null;
|
||||
stcwDepartment?: SeafarerDepartment | null;
|
||||
competencyLevel?: CompetencyLevel | null;
|
||||
stcwFunctions?: StcwFunctionRow[] | null;
|
||||
stcwCapacities?: StcwCapacityRow[] | null;
|
||||
/** Licence keys that must be held before this may be applied for. */
|
||||
prerequisiteLicenseKeys?: string[] | null;
|
||||
}
|
||||
|
||||
/** What kind of document a certificate type produces. */
|
||||
export type CertificateCategory =
|
||||
| "COC"
|
||||
| "COP"
|
||||
| "ENDORSEMENT"
|
||||
| "GOC"
|
||||
| "NATIONAL";
|
||||
|
||||
/**
|
||||
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
|
||||
* a seafarer in training before holding any certificate, not a level of
|
||||
* competence.
|
||||
*/
|
||||
export type CompetencyLevel = "SUPPORT" | "OPERATIONAL" | "MANAGEMENT";
|
||||
|
||||
/** One row of a CoC's function table (STCW Code A-I/2). */
|
||||
export interface StcwFunctionRow {
|
||||
function: Bilingual;
|
||||
level: CompetencyLevel;
|
||||
limitation?: Bilingual;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/** One row of a CoC's capacity table — what the holder may serve as. */
|
||||
export interface StcwCapacityRow {
|
||||
capacity: Bilingual;
|
||||
limitation?: Bilingual;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface DocumentRequirement {
|
||||
@@ -380,6 +444,11 @@ export interface LicenseTemplate {
|
||||
version: number;
|
||||
hbsSource: string;
|
||||
pageOptions: TemplatePageOptions | null;
|
||||
/**
|
||||
* Artwork printed under the rendered text. A background, not the whole
|
||||
* certificate — per-certificate data stays in the template layer above it.
|
||||
*/
|
||||
backgroundUrl?: string | null;
|
||||
status: TemplateStatus;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -7,15 +7,33 @@ export const SESSION_HEADER_KEYS = {
|
||||
currentProjectId: 'x-current-project-id',
|
||||
} as const;
|
||||
|
||||
const TOKEN_STORAGE_KEYS = [
|
||||
'ema-backoffice-auth-token',
|
||||
'ema-portal-auth-token',
|
||||
'auth-token',
|
||||
] as const;
|
||||
/**
|
||||
* Which app this bundle is, so it reads its own session and no one else's.
|
||||
*
|
||||
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
|
||||
* scope the backoffice would happily authenticate as whoever last signed into
|
||||
* the portal, and render a staff console with an applicant's permissions.
|
||||
*/
|
||||
let scopedTokenKey: string | undefined;
|
||||
|
||||
export function configureSessionScope(prefix: string): void {
|
||||
scopedTokenKey = `${prefix}-auth-token`;
|
||||
}
|
||||
|
||||
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
|
||||
const LEGACY_TOKEN_KEY = 'auth-token';
|
||||
|
||||
export function resolveTokenFromStorage(): string | undefined {
|
||||
// cookie first, then localStorage (legacy pre-migration sessions)
|
||||
for (const key of TOKEN_STORAGE_KEYS) {
|
||||
// Only this app's key, then the legacy unprefixed one. Never another app's:
|
||||
// falling through to a sibling's token is how a backoffice tab ends up
|
||||
// holding a portal session.
|
||||
const keys = scopedTokenKey
|
||||
? [scopedTokenKey, LEGACY_TOKEN_KEY]
|
||||
: [LEGACY_TOKEN_KEY];
|
||||
|
||||
for (const key of keys) {
|
||||
// cookie first, then localStorage (legacy pre-migration sessions)
|
||||
const cookie = Cookies.get(key);
|
||||
if (cookie) return cookie;
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
Reference in New Issue
Block a user