mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Adding license generation functionality
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export * from './lib/base-api';
|
||||
export * from './lib/query-and-mutation';
|
||||
export * from './lib/session';
|
||||
export * from './lib/features/licensing';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
|
||||
3
libs/api/src/lib/features/licensing/index.ts
Normal file
3
libs/api/src/lib/features/licensing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './licensing.types';
|
||||
export * from './licensing-api';
|
||||
export * from './licensing.helpers';
|
||||
434
libs/api/src/lib/features/licensing/licensing-api.ts
Normal file
434
libs/api/src/lib/features/licensing/licensing-api.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { baseApi } from '../../base-api';
|
||||
import type {
|
||||
AppNotification,
|
||||
ApplicationDetail,
|
||||
ApplicationKind,
|
||||
ApplicationPayment,
|
||||
ApplicationStaff,
|
||||
Attachment,
|
||||
InitiatePaymentResult,
|
||||
IssuedLicense,
|
||||
Inspection,
|
||||
LicenseApplication,
|
||||
LicenseCategoryDefinition,
|
||||
LicenseStatus,
|
||||
LicenseType,
|
||||
LicenseTypeRequirements,
|
||||
Paginated,
|
||||
RemarkTargetType,
|
||||
} from './licensing.types';
|
||||
|
||||
const TAGS = [
|
||||
'LicenseType',
|
||||
'LicenseApplication',
|
||||
'ApplicationQueue',
|
||||
'Attachment',
|
||||
'Notification',
|
||||
'Inspection',
|
||||
'License',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
const itemTag = (type: (typeof TAGS)[number], id: string) => ({ type, id }) as const;
|
||||
|
||||
/**
|
||||
* Typed licensing endpoints, shared by the portal and the backoffice.
|
||||
*
|
||||
* Each resource gets its own cache tag so a transition invalidates the one
|
||||
* application and its queue, rather than the whole store.
|
||||
*/
|
||||
export const licensingApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: TAGS })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// ------------------------------------------------------------- config
|
||||
/** Active licence types an applicant can choose from. */
|
||||
getLicenseTypes: builder.query<Paginated<LicenseType>, void>({
|
||||
query: () => ({ url: '/license-types' }),
|
||||
providesTags: () => [listTag('LicenseType')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Category catalogue used to group the licence cards. Static on the
|
||||
* server, so it is cached under the licence-type list tag.
|
||||
*/
|
||||
getLicenseCategories: builder.query<
|
||||
Paginated<LicenseCategoryDefinition>,
|
||||
void
|
||||
>({
|
||||
query: () => ({ url: '/license-categories' }),
|
||||
providesTags: () => [listTag('LicenseType')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fee configuration, for licensing administrators.
|
||||
*
|
||||
* `null` is a meaningful value for both amounts and must survive the
|
||||
* round trip: null on the new-application fee means the licence is free,
|
||||
* null on the renewal fee means it is charged at the new-application
|
||||
* rate. Sending `undefined` instead would leave the column untouched.
|
||||
*/
|
||||
updateLicenseFees: builder.mutation<
|
||||
LicenseType,
|
||||
{
|
||||
id: string;
|
||||
feeNewApplication?: number | null;
|
||||
feeRenewal?: number | null;
|
||||
feeCurrency?: string;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-types/${id}/fees`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
getLicenseTypeRequirements: builder.query<
|
||||
LicenseTypeRequirements,
|
||||
{ idOrKey: string; kind?: ApplicationKind }
|
||||
>({
|
||||
query: ({ idOrKey, kind = 'NEW' }) => ({
|
||||
url: `/license-types/requirements/${idOrKey}`,
|
||||
params: { kind },
|
||||
}),
|
||||
providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)],
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- application
|
||||
createApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ licenseType: string; kind?: ApplicationKind; previousLicenseId?: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/license-applications', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
|
||||
}),
|
||||
|
||||
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
|
||||
query: () => ({ url: '/license-applications/mine' }),
|
||||
providesTags: () => [listTag('LicenseApplication')],
|
||||
}),
|
||||
|
||||
getApplication: builder.query<ApplicationDetail, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
patchSection: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; sectionKey: string; values: Record<string, unknown> }
|
||||
>({
|
||||
query: ({ id, sectionKey, values }) => ({
|
||||
url: `/license-applications/${id}/sections/${sectionKey}`,
|
||||
method: 'PATCH',
|
||||
body: { values },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
addStaff: builder.mutation<
|
||||
ApplicationStaff,
|
||||
{ id: string; roleKey: string; fullName: string; position?: string; yearsOfExperience?: number }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-applications/${id}/staff`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
removeStaff: builder.mutation<unknown, { id: string; staffId: string }>({
|
||||
query: ({ id, staffId }) => ({
|
||||
url: `/license-applications/${id}/staff/${staffId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
submitApplication: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}/submit`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
resolveRemark: builder.mutation<unknown, { id: string; remarkId: string }>({
|
||||
query: ({ id, remarkId }) => ({
|
||||
url: `/license-applications/${id}/remarks/${remarkId}/resolve`,
|
||||
method: 'PATCH',
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
resubmitApplication: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}/resubmit`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')],
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- attachments
|
||||
getAttachments: builder.query<Attachment[], { ownerType: string; ownerId: string }>({
|
||||
query: ({ ownerType, ownerId }) => ({
|
||||
url: '/attachments',
|
||||
params: { ownerType, ownerId, withUrls: true },
|
||||
}),
|
||||
providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)],
|
||||
}),
|
||||
|
||||
deleteAttachment: builder.mutation<unknown, { attachmentId: string; ownerId: string }>({
|
||||
query: ({ attachmentId }) => ({
|
||||
url: `/attachments/${attachmentId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, error, { ownerId }) =>
|
||||
error ? [] : [itemTag('Attachment', ownerId)],
|
||||
}),
|
||||
|
||||
// ----------------------------------------------------------- payments
|
||||
initiatePayment: builder.mutation<
|
||||
InitiatePaymentResult,
|
||||
{ id: string; provider?: string; platform?: 'web' | 'mobile'; payerAccount?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-applications/${id}/payments/initiate`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
getApplicationPayment: builder.query<ApplicationPayment, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}/payments` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
/** Testing shortcut — only offered when the API reports it enabled. */
|
||||
bypassPayment: builder.mutation<
|
||||
{ status: LicenseStatus; certificateIssued: boolean },
|
||||
string
|
||||
>({
|
||||
query: (id) => ({
|
||||
url: `/license-applications/${id}/payments/bypass`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error
|
||||
? []
|
||||
: [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('License')],
|
||||
}),
|
||||
|
||||
getPaymentCapabilities: builder.query<{ bypassEnabled: boolean }, void>({
|
||||
query: () => ({ url: '/license-applications/payments/capabilities' }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------ licences
|
||||
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
|
||||
query: () => ({ url: '/licenses/mine' }),
|
||||
providesTags: () => [listTag('License')],
|
||||
}),
|
||||
|
||||
getCertificateUrl: builder.mutation<{ url: string }, string>({
|
||||
query: (id) => ({ url: `/licenses/${id}/certificate` }),
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------- review
|
||||
getQueue: builder.query<
|
||||
Paginated<LicenseApplication>,
|
||||
{ licenseTypeId?: string; search?: string } | void
|
||||
>({
|
||||
query: (params) => ({ url: '/license-application-review/queue', params: params ?? {} }),
|
||||
providesTags: () => [listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
getAssignedToMe: builder.query<
|
||||
Paginated<LicenseApplication>,
|
||||
{ licenseTypeId?: string; search?: string } | void
|
||||
>({
|
||||
query: (params) => ({
|
||||
url: '/license-application-review/assigned-to-me',
|
||||
params: params ?? {},
|
||||
}),
|
||||
providesTags: () => [listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
getApplicationForReview: builder.query<ApplicationDetail, string>({
|
||||
query: (id) => ({ url: `/license-application-review/${id}` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
claimApplication: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({ url: `/license-application-review/${id}/claim`, method: 'POST' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
completeReview: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; capitalAmountVerified?: number; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/complete-review`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
requestAdjustment: builder.mutation<
|
||||
LicenseApplication,
|
||||
{
|
||||
id: string;
|
||||
generalRemark?: string;
|
||||
items: { targetType: RemarkTargetType; targetKey: string; remark: string }[];
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/request-adjustment`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
approveDocuments: builder.mutation<LicenseApplication, { id: string; remark?: string }>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/approve-documents`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* `capitalAmountVerified` is what the officer confirms against the bank
|
||||
* letter. Licence types with a capital threshold reject the approval
|
||||
* without it, so it is part of the contract, not an optional extra.
|
||||
*/
|
||||
finalApprove: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; remark?: string; capitalAmountVerified?: number }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/final-approve`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
rejectApplication: builder.mutation<LicenseApplication, { id: string; reason: string }>({
|
||||
query: ({ id, reason }) => ({
|
||||
url: `/license-application-review/${id}/reject`,
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
confirmPayment: builder.mutation<LicenseApplication, string>({
|
||||
query: (id) => ({
|
||||
url: `/license-application-review/${id}/confirm-payment`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{ applicationId: string; scheduledDate: string; location?: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/inspections', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
getInspections: builder.query<Inspection[], string>({
|
||||
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
|
||||
providesTags: () => [listTag('Inspection')],
|
||||
}),
|
||||
|
||||
recordInspectionResult: builder.mutation<
|
||||
Inspection,
|
||||
{ inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string }
|
||||
>({
|
||||
query: ({ inspectionId, result, findings }) => ({
|
||||
url: `/inspections/${inspectionId}/result`,
|
||||
method: 'PATCH',
|
||||
body: { result, findings },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------ notifications
|
||||
getNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({
|
||||
query: () => ({ url: '/notifications' }),
|
||||
providesTags: () => [listTag('Notification')],
|
||||
}),
|
||||
|
||||
getUnseenNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({
|
||||
query: () => ({ url: '/notifications/unseen' }),
|
||||
providesTags: () => [listTag('Notification')],
|
||||
}),
|
||||
|
||||
markNotificationRead: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/notifications/${id}/read`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Notification')]),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetApplicationQuery,
|
||||
useInitiatePaymentMutation,
|
||||
useBypassPaymentMutation,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetApplicationPaymentQuery,
|
||||
usePatchSectionMutation,
|
||||
useAddStaffMutation,
|
||||
useRemoveStaffMutation,
|
||||
useSubmitApplicationMutation,
|
||||
useResolveRemarkMutation,
|
||||
useResubmitApplicationMutation,
|
||||
useGetAttachmentsQuery,
|
||||
useDeleteAttachmentMutation,
|
||||
useGetQueueQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetApplicationForReviewQuery,
|
||||
useClaimApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useRequestAdjustmentMutation,
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
useRecordInspectionResultMutation,
|
||||
useGetNotificationsQuery,
|
||||
useGetUnseenNotificationsQuery,
|
||||
useMarkNotificationReadMutation,
|
||||
} = licensingApi;
|
||||
341
libs/api/src/lib/features/licensing/licensing.helpers.ts
Normal file
341
libs/api/src/lib/features/licensing/licensing.helpers.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FormSectionConfig,
|
||||
LicenseStatus,
|
||||
ValidationIssue,
|
||||
} from './licensing.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
*
|
||||
* Files go up the moment they're picked rather than being held until submit —
|
||||
* the application already exists as a draft, so there is a real owner to
|
||||
* attach them to, and nothing can be lost if the browser closes mid-wizard.
|
||||
*/
|
||||
export async function uploadDocument(params: {
|
||||
ownerType: 'APPLICATION' | 'APPLICATION_STAFF' | 'INSPECTION' | 'LICENSE';
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
file: File;
|
||||
title?: string;
|
||||
validFrom?: string;
|
||||
validTo?: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const form = new FormData();
|
||||
form.append('ownerType', params.ownerType);
|
||||
form.append('ownerId', params.ownerId);
|
||||
form.append('documentKey', params.documentKey);
|
||||
if (params.title) form.append('title', params.title);
|
||||
if (params.validFrom) form.append('validFrom', params.validFrom);
|
||||
if (params.validTo) form.append('validTo', params.validTo);
|
||||
form.append('files', params.file, params.file.name);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
const res = await fetch(`${BASE_API_URL}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, error: body?.message ?? `Upload failed (${res.status})` };
|
||||
}
|
||||
|
||||
/** Human label for a status, in the vocabulary the user stories use. */
|
||||
export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
};
|
||||
|
||||
/** Mantine colour per status — green progresses, orange needs the applicant. */
|
||||
export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
INSPECTION_COMPLETED: 'cyan',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
PAYMENT_PENDING: 'yellow',
|
||||
PAID: 'lime',
|
||||
PAYMENT_CONFIRMED: 'teal',
|
||||
CERTIFICATE_ISSUED: 'green',
|
||||
COMPLETED: 'green',
|
||||
};
|
||||
|
||||
/**
|
||||
* Roughly how far through the lifecycle a status sits, as a percentage.
|
||||
*
|
||||
* Used only to draw a progress bar. The two terminal outcomes both read as
|
||||
* finished — a rejection is the end of the road, not 60% of the way somewhere.
|
||||
*/
|
||||
export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
DRAFT: 5,
|
||||
SUBMITTED: 15,
|
||||
UNDER_REVIEW: 30,
|
||||
UNDER_EVALUATION: 45,
|
||||
RESUBMIT_REQUIRED: 30,
|
||||
INSPECTION_PENDING: 55,
|
||||
INSPECTION_COMPLETED: 65,
|
||||
APPROVED: 75,
|
||||
PAYMENT_PENDING: 80,
|
||||
PAID: 88,
|
||||
PAYMENT_CONFIRMED: 94,
|
||||
CERTIFICATE_ISSUED: 100,
|
||||
COMPLETED: 100,
|
||||
REJECTED: 100,
|
||||
};
|
||||
|
||||
/** Statuses where nothing moves until the applicant does something. */
|
||||
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
|
||||
'DRAFT',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'PAYMENT_PENDING',
|
||||
];
|
||||
|
||||
/** Statuses that are finished, whichever way they went. */
|
||||
export const TERMINAL_STATUSES: LicenseStatus[] = [
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||
if (!value) return '';
|
||||
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the per-field issue list out of a failed submit so the wizard can say
|
||||
* exactly what is missing instead of a generic error.
|
||||
*/
|
||||
export function extractValidationIssues(error: unknown): ValidationIssue[] {
|
||||
const data = (error as { data?: Record<string, unknown> })?.data;
|
||||
if (!data) return [];
|
||||
const issues = data['issues'];
|
||||
return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server error keys rendered for a human.
|
||||
*
|
||||
* The API answers with stable machine keys; showing those raw (e.g.
|
||||
* "capital_verification_required") tells an officer nothing about what to do.
|
||||
*/
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
capital_verification_required:
|
||||
'Record the verified capital before approving — it must be checked against the bank letter.',
|
||||
capital_below_threshold:
|
||||
'The verified capital is below the minimum required for this licence type.',
|
||||
inspection_required_before_approval:
|
||||
'A physical inspection must be recorded before this application can be approved.',
|
||||
application_already_claimed: 'Another officer has already claimed this application.',
|
||||
assigned_to_another_officer:
|
||||
'This application is assigned to another officer, so only they can act on it.',
|
||||
not_application_owner: 'This application belongs to another applicant.',
|
||||
application_incomplete: 'The application is missing required information.',
|
||||
unresolved_remarks: 'Every requested correction must be addressed first.',
|
||||
adjustment_items_required: 'Flag at least one item before requesting an adjustment.',
|
||||
remark_text_required: 'Each flagged item needs a remark explaining what to fix.',
|
||||
rejection_reason_required: 'A rejection reason is required.',
|
||||
section_not_flagged_for_adjustment:
|
||||
'Only the sections the officer flagged can be changed in this round.',
|
||||
application_not_editable: 'This application can no longer be edited.',
|
||||
invalid_transition: 'That action is not available at this stage.',
|
||||
inspection_not_required_use_final_approve:
|
||||
'This licence type needs no inspection — approve it directly.',
|
||||
application_not_awaiting_inspection:
|
||||
'This application is not waiting for an inspection.',
|
||||
inspection_already_completed: 'This inspection has already been recorded.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
};
|
||||
|
||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||
const data = (error as { data?: Record<string, unknown> })?.data;
|
||||
const raw = data?.['message'];
|
||||
|
||||
// The filter passes structured errors through as `{ message: { message } }`.
|
||||
const key =
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
: typeof (raw as { message?: unknown })?.message === 'string'
|
||||
? ((raw as { message: string }).message)
|
||||
: undefined;
|
||||
|
||||
if (key && ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** One wizard step: either a group of form sections, or a fixed stage. */
|
||||
export interface WizardStep {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'sections' | 'staff' | 'documents' | 'review';
|
||||
sections: FormSectionConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the configured sections into wizard steps.
|
||||
*
|
||||
* Sections that share a `group` collapse onto one step, which is what keeps
|
||||
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
|
||||
* type that has not been grouped still behaves exactly as before.
|
||||
*/
|
||||
export function buildWizardSteps(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): WizardStep[] {
|
||||
const visible = [...sections]
|
||||
.filter((section) => conditionHolds(section.showWhen, formData))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
|
||||
const steps: WizardStep[] = [];
|
||||
const byGroup = new Map<string, WizardStep>();
|
||||
|
||||
for (const section of visible) {
|
||||
const group = section.group?.trim();
|
||||
if (!group) {
|
||||
steps.push({
|
||||
key: `section:${section.key}`,
|
||||
label: localized(section.title),
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const existing = byGroup.get(group);
|
||||
if (existing) {
|
||||
existing.sections.push(section);
|
||||
continue;
|
||||
}
|
||||
const step: WizardStep = {
|
||||
key: `group:${group}`,
|
||||
label: group,
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
};
|
||||
byGroup.set(group, step);
|
||||
steps.push(step);
|
||||
}
|
||||
|
||||
// Honour explicit group ordering where given, keeping config order otherwise.
|
||||
const orderOf = (step: WizardStep) =>
|
||||
Math.min(...step.sections.map((s) => s.groupOrder ?? s.sortOrder ?? 0));
|
||||
steps.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
|
||||
// "Review" is a reserved group: those sections render on the final step
|
||||
// beside the summary rather than earning a step of their own. It is what
|
||||
// keeps a one-checkbox declaration from costing a whole page.
|
||||
const isReviewGroup = (step: WizardStep) =>
|
||||
step.sections.some((s) => s.group?.trim().toLowerCase() === 'review');
|
||||
const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
|
||||
const formSteps = steps.filter((step) => !isReviewGroup(step));
|
||||
|
||||
return [
|
||||
...formSteps,
|
||||
{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] },
|
||||
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
|
||||
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||
];
|
||||
}
|
||||
|
||||
/** `${sectionKey}.${fieldKey}` → message, for inline display under a field. */
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Checks the required fields of the given sections against the answers,
|
||||
* skipping anything a condition currently hides. Used to stop the wizard
|
||||
* advancing past an incomplete step rather than failing only at submit.
|
||||
*/
|
||||
export function validateSections(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
for (const section of sections) {
|
||||
if (!conditionHolds(section.showWhen, formData)) continue;
|
||||
const values = formData[section.key] ?? {};
|
||||
|
||||
for (const field of section.fields ?? []) {
|
||||
if (!conditionHolds(field.showWhen, formData)) continue;
|
||||
// Read-only values are supplied by the server, not the applicant.
|
||||
if (field.readOnly && field.source) continue;
|
||||
|
||||
const value = values[field.key];
|
||||
const empty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(typeof value === 'boolean' && value === false) ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (field.required && empty) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
field.type === 'BOOLEAN'
|
||||
? `${localized(field.label)} must be accepted`
|
||||
: `${localized(field.label)} is required`;
|
||||
continue;
|
||||
}
|
||||
if (empty) continue;
|
||||
|
||||
const numeric = Number(value);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
if (field.min !== undefined && numeric < field.min) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
`Must be at least ${field.min.toLocaleString()}`;
|
||||
} else if (field.max !== undefined && numeric > field.max) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
`Must not exceed ${field.max.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Evaluates a config condition against the current form answers. */
|
||||
export function conditionHolds(
|
||||
condition:
|
||||
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
|
||||
| undefined
|
||||
| null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!condition?.field) return true;
|
||||
const value = condition.field
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(acc, key) =>
|
||||
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
|
||||
formData as unknown,
|
||||
);
|
||||
const isEmpty = value === undefined || value === null || value === '';
|
||||
if (condition.isSet !== undefined) return condition.isSet ? !isEmpty : isEmpty;
|
||||
if (condition.equals !== undefined) return value === condition.equals;
|
||||
if (condition.notEquals !== undefined) return value !== condition.notEquals;
|
||||
if (condition.in !== undefined) return condition.in.includes(value as string | number);
|
||||
return true;
|
||||
}
|
||||
336
libs/api/src/lib/features/licensing/licensing.types.ts
Normal file
336
libs/api/src/lib/features/licensing/licensing.types.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/** Shared licensing contract — mirrors the emaapi domain model. */
|
||||
|
||||
export type Bilingual = { en?: string; am?: string };
|
||||
|
||||
/**
|
||||
* The application status vocabulary. Single source of truth for both apps —
|
||||
* previously this lived in a backoffice mock page.
|
||||
*/
|
||||
export type LicenseStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'UNDER_EVALUATION'
|
||||
| 'RESUBMIT_REQUIRED'
|
||||
| 'INSPECTION_PENDING'
|
||||
| 'INSPECTION_COMPLETED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'PAID'
|
||||
| 'PAYMENT_CONFIRMED'
|
||||
| 'CERTIFICATE_ISSUED'
|
||||
| 'COMPLETED';
|
||||
|
||||
export type ApplicationKind = 'NEW' | 'RENEWAL';
|
||||
|
||||
export type FormFieldType =
|
||||
| 'TEXT'
|
||||
| 'TEXTAREA'
|
||||
| 'NUMBER'
|
||||
| 'MONEY'
|
||||
| 'DATE'
|
||||
| 'SELECT'
|
||||
| 'BOOLEAN'
|
||||
| 'EMAIL'
|
||||
| 'PHONE'
|
||||
| 'TIN';
|
||||
|
||||
export interface FieldCondition {
|
||||
field: string;
|
||||
equals?: string | number | boolean;
|
||||
notEquals?: string | number | boolean;
|
||||
in?: (string | number)[];
|
||||
isSet?: boolean;
|
||||
}
|
||||
|
||||
export interface FormFieldConfig {
|
||||
key: string;
|
||||
label: Bilingual;
|
||||
type: FormFieldType;
|
||||
required?: boolean;
|
||||
helpText?: Bilingual;
|
||||
options?: { value: string; label: Bilingual }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
showWhen?: FieldCondition;
|
||||
readOnly?: boolean;
|
||||
source?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface FormSectionConfig {
|
||||
key: string;
|
||||
title: Bilingual;
|
||||
description?: Bilingual;
|
||||
fields: FormFieldConfig[];
|
||||
showWhen?: FieldCondition;
|
||||
sortOrder?: number;
|
||||
/** Sections sharing a group render together on one wizard step. */
|
||||
group?: string;
|
||||
/** Position of the group in the stepper; lowest value in a group wins. */
|
||||
groupOrder?: number;
|
||||
}
|
||||
|
||||
/** Grouping the portal organises the licence catalogue by. */
|
||||
export type LicenseCategory =
|
||||
| 'CARGO_FREIGHT'
|
||||
| 'SHIPPING_AGENCY'
|
||||
| 'INVESTMENT';
|
||||
|
||||
export interface LicenseCategoryDefinition {
|
||||
key: LicenseCategory;
|
||||
name: Bilingual;
|
||||
description: Bilingual;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface LicenseType {
|
||||
id: string;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
category: LicenseCategory;
|
||||
certificatePrefix: string;
|
||||
feeNewApplication: string | number | null;
|
||||
feeRenewal: string | number | null;
|
||||
feeCurrency: string;
|
||||
capitalThreshold: string | number | null;
|
||||
validityMonths: number;
|
||||
inspectionRequired: boolean;
|
||||
issuesCertificate: boolean;
|
||||
renewalEnabled: boolean;
|
||||
formSchema: { sections: FormSectionConfig[] };
|
||||
isActive: boolean;
|
||||
/** Display order set by EMA; lower comes first. */
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
applicationKind: ApplicationKind;
|
||||
mode: 'ALWAYS' | 'CONDITIONAL' | 'OPTIONAL';
|
||||
conditionExpression?: FieldCondition & { previousDocExpired?: string };
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface StaffEvidenceRequirement {
|
||||
docKey: string;
|
||||
label: Bilingual;
|
||||
mandatory: boolean;
|
||||
}
|
||||
|
||||
export interface StaffRoleRequirement {
|
||||
id: string;
|
||||
roleKey: string;
|
||||
name: Bilingual;
|
||||
minCount: number;
|
||||
maxCount: number | null;
|
||||
requiresExperience: boolean;
|
||||
minYearsExperience: number | null;
|
||||
requiredEvidence: StaffEvidenceRequirement[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface LicenseTypeRequirements {
|
||||
licenseType: LicenseType;
|
||||
applicationKind: ApplicationKind;
|
||||
fee: number | null;
|
||||
feeCurrency: string;
|
||||
documentRequirements: DocumentRequirement[];
|
||||
staffRoleRequirements: StaffRoleRequirement[];
|
||||
}
|
||||
|
||||
export interface LicenseApplication {
|
||||
id: string;
|
||||
applicationNumber: string;
|
||||
licenseTypeId: string;
|
||||
licenseType?: LicenseType;
|
||||
applicantUserId: string;
|
||||
kind: ApplicationKind;
|
||||
status: LicenseStatus;
|
||||
assignedOfficerId: string | null;
|
||||
claimedAt: string | null;
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
companyName: string | null;
|
||||
tradeName: string | null;
|
||||
tinNumber: string | null;
|
||||
businessAddress: string | null;
|
||||
capitalAmountDeclared: string | null;
|
||||
capitalAmountVerified: string | null;
|
||||
adjustmentRound: number;
|
||||
submittedAt: string | null;
|
||||
decidedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
feeAmount: string | null;
|
||||
feeCurrency: string;
|
||||
issuedLicenseId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApplicationStaff {
|
||||
id: string;
|
||||
roleKey: string;
|
||||
fullName: string;
|
||||
position: string | null;
|
||||
roleCategory: string | null;
|
||||
yearsOfExperience: number | null;
|
||||
documents?: { id: string; documentKey: string; files: AttachmentFile[] }[];
|
||||
}
|
||||
|
||||
export interface AttachmentFile {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
storageKey: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
ownerType: string;
|
||||
ownerId: string;
|
||||
documentKey: string;
|
||||
title: string | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
files: AttachmentFile[];
|
||||
}
|
||||
|
||||
export interface StatusHistoryEntry {
|
||||
id: string;
|
||||
fromStatus: LicenseStatus | null;
|
||||
toStatus: LicenseStatus;
|
||||
event: string;
|
||||
actorUserId: string | null;
|
||||
actorName: string | null;
|
||||
actorRole: string | null;
|
||||
remark: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type RemarkTargetType = 'FORM_SECTION' | 'DOCUMENT' | 'STAFF';
|
||||
|
||||
export interface ApplicationRemark {
|
||||
id: string;
|
||||
roundNumber: number;
|
||||
targetType: RemarkTargetType;
|
||||
targetKey: string;
|
||||
remark: string;
|
||||
isResolved: boolean;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
staff: ApplicationStaff[];
|
||||
attachments: Attachment[];
|
||||
history: StatusHistoryEntry[];
|
||||
remarks: ApplicationRemark[];
|
||||
openRemarks: ApplicationRemark[];
|
||||
availableEvents: string[];
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
inspectorId: string | null;
|
||||
inspectorName: string | null;
|
||||
scheduledDate: string | null;
|
||||
conductedDate: string | null;
|
||||
location: string | null;
|
||||
status: 'SCHEDULED' | 'COMPLETED' | 'CANCELLED';
|
||||
result: 'PASSED' | 'FAILED' | null;
|
||||
findings: string | null;
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
subject: Bilingual;
|
||||
content: Bilingual;
|
||||
isSeen: boolean;
|
||||
itemId: string | null;
|
||||
itemType: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** Per-field problems returned by the server when a submission is incomplete. */
|
||||
export interface ValidationIssue {
|
||||
kind: 'field' | 'document' | 'staff';
|
||||
target: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What the browser must do to complete a payment. */
|
||||
export interface ClientAction {
|
||||
type: 'REDIRECT' | 'LAUNCH_APP' | 'NONE';
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
shortCode?: string;
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
| 'PENDING'
|
||||
| 'PROCESSING'
|
||||
| 'PAID'
|
||||
| 'FAILED'
|
||||
| 'EXPIRED'
|
||||
| 'CANCELLED';
|
||||
|
||||
export interface InitiatePaymentResult {
|
||||
paymentId: string;
|
||||
intentId: string;
|
||||
status: PaymentStatus;
|
||||
amount: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
clientAction?: ClientAction;
|
||||
}
|
||||
|
||||
export interface ApplicationPayment {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
paymentIntentId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
provider: string;
|
||||
providerRef: string | null;
|
||||
merchantOrderId: string | null;
|
||||
status: PaymentStatus | null;
|
||||
checkoutUrl: string | null;
|
||||
paidAt: string | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** A licence certificate issued to the applicant. */
|
||||
export interface IssuedLicense {
|
||||
id: string;
|
||||
certificateNumber: string;
|
||||
licenseTypeId: string;
|
||||
licenseType?: LicenseType;
|
||||
applicationId: string;
|
||||
companyName: string | null;
|
||||
tinNumber: string | null;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
|
||||
verificationCode: string;
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user