mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +00:00
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:
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);
|
||||
};
|
||||
Reference in New Issue
Block a user