feat: add ExamStageActions component for exam fee handling

- Implemented ExamStageActions component to manage actions related to exam booking and payment based on application status.
- Added mock-base-query for development, providing a partial mock backend for various API endpoints.
- Introduced mock-data for simulating responses in the mock-base-query, covering profiles, vessels, applications, licenses, exams, and notifications.
This commit is contained in:
fitse-yotor
2026-08-15 11:51:14 +03:00
parent d8b01a2003
commit 0ac75669d4
34 changed files with 2638 additions and 472 deletions

View File

@@ -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: () => ({}),
});

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

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