mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects
# Conflicts: # apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx # libs/api/src/lib/features/licensing/licensing-api.ts # libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
@@ -14,6 +14,7 @@ import type {
|
||||
InitiatePaymentResult,
|
||||
IssuedLicense,
|
||||
Inspection,
|
||||
IssuancePeriod,
|
||||
LicenseApplication,
|
||||
LicenseCategoryDefinition,
|
||||
LicenseStatus,
|
||||
@@ -21,11 +22,16 @@ import type {
|
||||
LicenseTypeRequirements,
|
||||
OperatorType,
|
||||
AssignableOfficer,
|
||||
CertificateCategory,
|
||||
CompletionEffect,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
ExportResult,
|
||||
LicenseTemplate,
|
||||
Paginated,
|
||||
PickupAppointment,
|
||||
PickupOffice,
|
||||
PickupSlot,
|
||||
QueueCounts,
|
||||
QueueFilter,
|
||||
Rank,
|
||||
@@ -33,10 +39,14 @@ import type {
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
ServiceKind,
|
||||
TemplateFieldPlacement,
|
||||
TemplateLogoPlacement,
|
||||
TemplatePageOptions,
|
||||
TemplateVariable,
|
||||
PersonalDocumentFilter,
|
||||
PersonalDocumentGroup,
|
||||
WorkflowProfile,
|
||||
} from './licensing.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +73,16 @@ function serialiseQueueFilter(
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Sends only the facets that are set; `search=` would match nothing. */
|
||||
function dropEmpty(filter: object): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value === undefined || value === null || value === '' || value === false) continue;
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const TAGS = [
|
||||
'LicenseType',
|
||||
'OperatorType',
|
||||
@@ -75,8 +95,13 @@ const TAGS = [
|
||||
'SavedView',
|
||||
'LicenseTemplate',
|
||||
'DocumentRequirement',
|
||||
'PickupOffice',
|
||||
'PickupAppointment',
|
||||
'Department',
|
||||
'Rank',
|
||||
// Owned by the personal-document slice; named here so declaring a mode of
|
||||
// operation can invalidate the vault, whose slots depend on it.
|
||||
'PersonalDocument',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -121,9 +146,17 @@ export const licensingApi = baseApi
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
// The catalogue is filtered by this, so it has to refetch too.
|
||||
// The catalogue is filtered by this, so it has to refetch too — and so
|
||||
// is the personal document vault, which asks for the documents the
|
||||
// declared modes of operation need.
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
|
||||
error
|
||||
? []
|
||||
: [
|
||||
listTag('OperatorType'),
|
||||
listTag('LicenseType'),
|
||||
listTag('PersonalDocument'),
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -153,6 +186,12 @@ export const licensingApi = baseApi
|
||||
feeNewApplication?: number | null;
|
||||
feeRenewal?: number | null;
|
||||
feeCurrency?: string;
|
||||
// The examined-certificate stages. Unlike the two above, these are
|
||||
// read live off the licence type rather than snapshotted, so the
|
||||
// server refuses to clear one a candidate is currently waiting on.
|
||||
feeEligibility?: number | null;
|
||||
feeExamination?: number | null;
|
||||
feeCertificate?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
@@ -164,6 +203,53 @@ export const licensingApi = baseApi
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
/**
|
||||
* How a licence type behaves: its workflow, eligibility gates, renewal
|
||||
* policy and applicant rules — everything that used to be settable only
|
||||
* by editing a seed file.
|
||||
*
|
||||
* Three of these (`workflowProfile`, `completionEffect`,
|
||||
* `requiresExamination`) decide the course an application runs, and are
|
||||
* read live rather than snapshotted. The server answers 409
|
||||
* `license_type_in_use` when changing one would strand applications that
|
||||
* have not yet had their approval decision.
|
||||
*/
|
||||
updateLicenseBehavior: builder.mutation<
|
||||
LicenseType,
|
||||
{
|
||||
id: string;
|
||||
workflowProfile?: WorkflowProfile;
|
||||
serviceKind?: ServiceKind;
|
||||
completionEffect?: CompletionEffect | null;
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
requiresExamination?: boolean;
|
||||
inspectionRequired?: boolean;
|
||||
issuesCertificate?: boolean;
|
||||
renewalEnabled?: boolean;
|
||||
requiresSeafarerRegistration?: boolean;
|
||||
requiresValidMedical?: boolean;
|
||||
minSeaTimeDays?: number | null;
|
||||
validityMonths?: number;
|
||||
validityDays?: number | null;
|
||||
capitalThreshold?: number | null;
|
||||
renewalWindowDays?: number;
|
||||
expiryReminderDays?: number[];
|
||||
requiresOperatorMode?: boolean;
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
requiresIssuanceScheduling?: boolean;
|
||||
uniqueFormKeyPath?: string | null;
|
||||
slaHours?: number | null;
|
||||
}
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-types/${id}/behavior`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
|
||||
}),
|
||||
|
||||
/** Validity is edited beside the certificate design, not with the fees. */
|
||||
updateLicenseValidity: builder.mutation<
|
||||
LicenseType,
|
||||
@@ -234,9 +320,30 @@ export const licensingApi = baseApi
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Personal document slots, grouped by key and paged by the server.
|
||||
*
|
||||
* Its own endpoint rather than filtering `getDocumentRequirements` in the
|
||||
* browser: one document can be configured against several licence types,
|
||||
* so a page of rows would split a document in half and misreport its
|
||||
* scope. The server groups first, then pages.
|
||||
*/
|
||||
getPersonalDocuments: builder.query<
|
||||
Paginated<PersonalDocumentGroup>,
|
||||
PersonalDocumentFilter | void
|
||||
>({
|
||||
query: (filter) => ({
|
||||
url: '/document-requirements/personal',
|
||||
params: dropEmpty(filter ?? {}),
|
||||
}),
|
||||
providesTags: () => [listTag('DocumentRequirement')],
|
||||
}),
|
||||
|
||||
createDocumentRequirement: builder.mutation<
|
||||
DocumentRequirement,
|
||||
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
|
||||
// No `licenseTypeId` means a personal document, required for every
|
||||
// licence and served from the applicant's own vault.
|
||||
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
@@ -337,6 +444,11 @@ export const licensingApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
|
||||
}),
|
||||
|
||||
discardApplication: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
|
||||
}),
|
||||
|
||||
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
|
||||
query: () => ({ url: '/license-applications/mine' }),
|
||||
providesTags: () => [listTag('LicenseApplication')],
|
||||
@@ -958,12 +1070,12 @@ export const licensingApi = baseApi
|
||||
|
||||
scheduleIssuance: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; scheduledDate: string }
|
||||
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
|
||||
>({
|
||||
query: ({ id, scheduledDate }) => ({
|
||||
query: ({ id, scheduledDate, scheduledPeriod }) => ({
|
||||
url: `/license-application-review/${id}/schedule-issuance`,
|
||||
method: 'POST',
|
||||
body: { scheduledDate },
|
||||
body: { scheduledDate, scheduledPeriod },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
@@ -978,6 +1090,104 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
// ------------------------------------------------------------- pickup
|
||||
getPickupOffices: builder.query<PickupOffice[], void>({
|
||||
query: () => ({ url: '/pickup/offices' }),
|
||||
providesTags: [listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
getPickupSlots: builder.query<
|
||||
PickupSlot[],
|
||||
{ officeId: string; from: string; to: string }
|
||||
>({
|
||||
query: ({ officeId, from, to }) => ({
|
||||
url: `/pickup/offices/${officeId}/slots`,
|
||||
params: { from, to },
|
||||
}),
|
||||
}),
|
||||
|
||||
schedulePickup: builder.mutation<
|
||||
PickupAppointment,
|
||||
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error
|
||||
? []
|
||||
: [
|
||||
itemTag('LicenseApplication', applicationId),
|
||||
listTag('ApplicationQueue'),
|
||||
listTag('PickupAppointment'),
|
||||
],
|
||||
}),
|
||||
|
||||
reschedulePickup: builder.mutation<
|
||||
PickupAppointment,
|
||||
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
|
||||
>({
|
||||
query: ({ appointmentId, ...body }) => ({
|
||||
url: `/pickup/appointments/${appointmentId}/reschedule`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { appointmentId }) =>
|
||||
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
|
||||
query: (applicationId) => ({
|
||||
url: `/pickup/applications/${applicationId}/appointments`,
|
||||
}),
|
||||
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
|
||||
}),
|
||||
|
||||
getPickupWorklist: builder.query<
|
||||
PickupAppointment[],
|
||||
{ date: string; officeId?: string }
|
||||
>({
|
||||
query: ({ date, officeId }) => ({
|
||||
url: '/pickup/appointments',
|
||||
params: officeId ? { date, officeId } : { date },
|
||||
}),
|
||||
providesTags: [listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
checkInPickup: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
markPickupIssued: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
markPickupNoShow: builder.mutation<PickupAppointment, string>({
|
||||
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
|
||||
invalidatesTags: (_r, error, id) =>
|
||||
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
|
||||
}),
|
||||
|
||||
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
|
||||
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
|
||||
invalidatesTags: [listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
updatePickupOffice: builder.mutation<
|
||||
PickupOffice,
|
||||
{ id: string } & Partial<PickupOffice>
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/pickup/offices/${id}`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
|
||||
}),
|
||||
|
||||
// --------------------------------------------------------- inspection
|
||||
scheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
@@ -993,6 +1203,30 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
/** Moves a booked visit: another day, slot, inspector or place. */
|
||||
rescheduleInspection: builder.mutation<
|
||||
Inspection,
|
||||
{
|
||||
inspectionId: string;
|
||||
applicationId: string;
|
||||
scheduledDate: string;
|
||||
timeSlot: 'MORNING' | 'AFTERNOON';
|
||||
inspectorId?: string;
|
||||
location?: string;
|
||||
reason?: string;
|
||||
}
|
||||
>({
|
||||
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
|
||||
url: `/inspections/${inspectionId}/schedule`,
|
||||
method: 'PATCH',
|
||||
// applicationId is for cache invalidation only; the visit knows its
|
||||
// own application.
|
||||
body: { scheduledDate, timeSlot, inspectorId, location, reason },
|
||||
}),
|
||||
invalidatesTags: (_r, error, { applicationId }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
|
||||
}),
|
||||
|
||||
getInspections: builder.query<Inspection[], string>({
|
||||
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
|
||||
providesTags: () => [listTag('Inspection')],
|
||||
@@ -1046,10 +1280,12 @@ export const {
|
||||
useGetLicenseTypesQuery,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
useUpdateLicenseBehaviorMutation,
|
||||
useUpdateFormSchemaMutation,
|
||||
useValidateFormSchemaMutation,
|
||||
useGetFormSchemaPaletteQuery,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetPersonalDocumentsQuery,
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
@@ -1067,6 +1303,7 @@ export const {
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
useGetExamStateForApplicationQuery,
|
||||
useDiscardApplicationMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetApplicationQuery,
|
||||
useInitiatePaymentMutation,
|
||||
@@ -1127,7 +1364,19 @@ export const {
|
||||
useConfirmPaymentMutation,
|
||||
useScheduleIssuanceMutation,
|
||||
useIssueCertificateMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useGetPickupSlotsQuery,
|
||||
useSchedulePickupMutation,
|
||||
useReschedulePickupMutation,
|
||||
useGetPickupAppointmentsForApplicationQuery,
|
||||
useGetPickupWorklistQuery,
|
||||
useCheckInPickupMutation,
|
||||
useMarkPickupIssuedMutation,
|
||||
useMarkPickupNoShowMutation,
|
||||
useCreatePickupOfficeMutation,
|
||||
useUpdatePickupOfficeMutation,
|
||||
useScheduleInspectionMutation,
|
||||
useRescheduleInspectionMutation,
|
||||
useGetInspectionsQuery,
|
||||
useRecordInspectionResultMutation,
|
||||
useGetNotificationsQuery,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
|
||||
import type {
|
||||
ApplicationKind,
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FieldCondition,
|
||||
@@ -11,9 +13,10 @@ import type {
|
||||
ValidationIssue,
|
||||
} from './licensing.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
|
||||
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
|
||||
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
@@ -63,6 +66,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
@@ -95,6 +99,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
AWAITING_BIOMETRICS: 'indigo',
|
||||
UNDER_EVALUATION: 'indigo',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
INSPECTION_PENDING: 'cyan',
|
||||
@@ -134,6 +139,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
DRAFT: 5,
|
||||
SUBMITTED: 15,
|
||||
UNDER_REVIEW: 30,
|
||||
AWAITING_BIOMETRICS: 30,
|
||||
UNDER_EVALUATION: 45,
|
||||
RESUBMIT_REQUIRED: 30,
|
||||
INSPECTION_PENDING: 55,
|
||||
@@ -396,6 +402,15 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
|
||||
// Licence-type configuration guards. Each of these refuses a change that
|
||||
// would leave applications already in progress unable to move.
|
||||
license_type_in_use:
|
||||
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
|
||||
stage_fee_in_use:
|
||||
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
|
||||
form_schema_missing_protected_paths:
|
||||
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
|
||||
};
|
||||
|
||||
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
|
||||
@@ -451,12 +466,28 @@ export function buildWizardSteps(
|
||||
* instead of showing an empty page.
|
||||
*/
|
||||
hasStaff?: boolean;
|
||||
/**
|
||||
* Whether this application has any document requirements to upload.
|
||||
* False for a Damaged/Reissue application, which asks nothing beyond the
|
||||
* Damage Information step — showing an empty Documents page would be a
|
||||
* page to click past for nothing.
|
||||
*/
|
||||
hasDocuments?: boolean;
|
||||
/** Active UI language. Components get this from `useLocalized`; this is a
|
||||
* pure function, so the caller passes `i18n.language` through. */
|
||||
language?: string;
|
||||
/**
|
||||
* The application's kind — NEW unless the caller is renewing or
|
||||
* reissuing. A section scoped to a different kind via
|
||||
* `applicationKinds` is left out entirely, the same as a `showWhen`
|
||||
* that never holds.
|
||||
*/
|
||||
applicationKind?: ApplicationKind;
|
||||
},
|
||||
): WizardStep[] {
|
||||
const kind = options?.applicationKind ?? 'NEW';
|
||||
const visible = [...sections]
|
||||
.filter((section) => sectionAppliesToKind(section, kind))
|
||||
.filter((section) => conditionHolds(section.showWhen, formData))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
|
||||
@@ -511,7 +542,9 @@ export function buildWizardSteps(
|
||||
...(options?.hasStaff === false
|
||||
? []
|
||||
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
|
||||
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
|
||||
...(options?.hasDocuments === false
|
||||
? []
|
||||
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
|
||||
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
|
||||
];
|
||||
}
|
||||
@@ -597,6 +630,49 @@ interface ConditionLike {
|
||||
anyOf?: ConditionLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Section keys a condition reads, recursing `anyOf`.
|
||||
*
|
||||
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
|
||||
* the section whose answer decides the condition.
|
||||
*/
|
||||
export function conditionSections(
|
||||
condition: FieldCondition | undefined | null,
|
||||
): string[] {
|
||||
if (!condition) return [];
|
||||
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
|
||||
if (!condition.field) return [];
|
||||
const [sectionKey] = condition.field.split('.');
|
||||
return sectionKey ? [sectionKey] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose visibility hangs on an answer in one of `flagged`.
|
||||
*
|
||||
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
|
||||
* that holds the vessel category is asking for an answer that decides which
|
||||
* fields in other sections are required, so those sections have to open too —
|
||||
* otherwise the applicant sees newly-required fields they cannot edit and
|
||||
* cannot resubmit.
|
||||
*/
|
||||
export function sectionsDependingOn(
|
||||
sections: FormSectionConfig[],
|
||||
flagged: Set<string>,
|
||||
): Set<string> {
|
||||
const dependent = new Set<string>();
|
||||
if (flagged.size === 0) return dependent;
|
||||
|
||||
for (const section of sections) {
|
||||
if (flagged.has(section.key)) continue;
|
||||
const reads = [
|
||||
section.showWhen,
|
||||
...(section.fields ?? []).map((field) => field.showWhen),
|
||||
].flatMap(conditionSections);
|
||||
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
|
||||
}
|
||||
return dependent;
|
||||
}
|
||||
|
||||
export function conditionHolds(
|
||||
condition: FieldCondition | undefined | null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
|
||||
@@ -25,6 +25,9 @@ export type LicenseStatus =
|
||||
| "DRAFT"
|
||||
| "SUBMITTED"
|
||||
| "UNDER_REVIEW"
|
||||
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
|
||||
// but approval is blocked until the applicant's profile has a BSID.
|
||||
| "AWAITING_BIOMETRICS"
|
||||
| "UNDER_EVALUATION"
|
||||
// Employee filed their review; parked with the team leader for a decision.
|
||||
| "REVIEW_REPORTED"
|
||||
@@ -60,7 +63,10 @@ export type LicenseStatus =
|
||||
| "EXAM_PASSED"
|
||||
| "EXAM_FAILED";
|
||||
|
||||
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
|
||||
|
||||
/** Half-day window a team leader books an applicant's document pickup into. */
|
||||
export type IssuancePeriod = "MORNING" | "AFTERNOON";
|
||||
|
||||
export type FormFieldType =
|
||||
| "TEXT"
|
||||
@@ -117,6 +123,12 @@ export interface FormSectionConfig {
|
||||
group?: string;
|
||||
/** Position of the group in the stepper; lowest value in a group wins. */
|
||||
groupOrder?: number;
|
||||
/**
|
||||
* Restricts this section to specific application kinds — e.g. the
|
||||
* Damaged/Reissue "Damage Information" step. Undefined or empty means
|
||||
* every kind.
|
||||
*/
|
||||
applicationKinds?: ApplicationKind[];
|
||||
}
|
||||
|
||||
/** Grouping the portal organises the licence catalogue by. */
|
||||
@@ -175,6 +187,11 @@ export interface LicenseType {
|
||||
feeCurrency: string;
|
||||
capitalThreshold: string | number | null;
|
||||
validityMonths: number;
|
||||
/**
|
||||
* A term in days instead of months, for licences shorter than a month can
|
||||
* express. Wins over `validityMonths` when set; null keeps calendar months.
|
||||
*/
|
||||
validityDays?: number | null;
|
||||
/**
|
||||
* Target turnaround in hours. Null means this type is not tracked against
|
||||
* an SLA, which the grid renders as "—" rather than as instantly overdue.
|
||||
@@ -204,11 +221,39 @@ export interface LicenseType {
|
||||
// --------------------------------------------------- examined certificates
|
||||
/** Approval establishes eligibility; the certificate is earned by exam. */
|
||||
requiresExamination?: boolean;
|
||||
/** Assessment fee, due on submission before any officer review. */
|
||||
feeEligibility?: string | number | null;
|
||||
/** 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;
|
||||
|
||||
// ------------------------------------------------------- behaviour config
|
||||
// Editable from the backoffice Behavior tab. Optional because the API has
|
||||
// only recently begun returning them and older mocks/fixtures omit them.
|
||||
|
||||
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
|
||||
serviceKind?: ServiceKind;
|
||||
/** Platform side effect fired when an application of this type completes. */
|
||||
completionEffect?: CompletionEffect | null;
|
||||
/** Only ACTIVE registered seafarers may apply. */
|
||||
requiresSeafarerRegistration?: boolean;
|
||||
/** Submission requires a current, unexpired medical certificate. */
|
||||
requiresValidMedical?: boolean;
|
||||
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
|
||||
minSeaTimeDays?: number | null;
|
||||
/** Whether several open drafts may exist at once, for per-asset registrations. */
|
||||
allowMultipleOpenDrafts?: boolean;
|
||||
/** Days before expiry that renewal opens. */
|
||||
renewalWindowDays?: number;
|
||||
/** Days before expiry to remind the holder, most distant first. */
|
||||
expiryReminderDays?: number[];
|
||||
/**
|
||||
* Dotted `formData` path whose answer may appear on only one live
|
||||
* application of this type. Null means no such rule.
|
||||
*/
|
||||
uniqueFormKeyPath?: string | null;
|
||||
|
||||
// ---------------------------------------------------------- STCW mapping
|
||||
certificateCategory?: CertificateCategory | null;
|
||||
stcwControlled?: boolean;
|
||||
@@ -252,7 +297,12 @@ export interface StcwCapacityRow {
|
||||
|
||||
export interface DocumentRequirement {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
/**
|
||||
* Null for a personal document — one every applicant keeps in their own
|
||||
* vault regardless of what they apply for, rather than a slot on one
|
||||
* licence's application form.
|
||||
*/
|
||||
licenseTypeId: string | null;
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
@@ -263,6 +313,14 @@ export interface DocumentRequirement {
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
/**
|
||||
* True for a personal document — one the applicant keeps in their own vault
|
||||
* — rather than an upload slot on an application form. Orthogonal to
|
||||
* `licenseTypeId`, which still says which licences it applies to.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
|
||||
maxFiles: number | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
@@ -346,6 +404,7 @@ export interface LicenseApplication {
|
||||
issuedLicenseId: string | null;
|
||||
/** Set once an officer schedules pickup for a document requiring in-person handover. */
|
||||
scheduledIssuanceDate: string | null;
|
||||
scheduledIssuancePeriod: IssuancePeriod | null;
|
||||
scheduledBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -437,6 +496,13 @@ export interface ApplicationApplicant {
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
/**
|
||||
* Current status of the license this application issued, independent of
|
||||
* the application's own (permanently historical) status — a later
|
||||
* reissue/renewal can supersede the license without changing what this
|
||||
* application itself accomplished. Null when nothing has been issued yet.
|
||||
*/
|
||||
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
|
||||
relatedApplications?: LicenseApplication[];
|
||||
/** Null when the applicant has no profile row (never expected in practice). */
|
||||
applicant: ApplicationApplicant | null;
|
||||
@@ -463,6 +529,50 @@ export interface Inspection {
|
||||
findings: string | null;
|
||||
}
|
||||
|
||||
export type PickupAppointmentStatus =
|
||||
| "SCHEDULED"
|
||||
| "CHECKED_IN"
|
||||
| "ISSUED"
|
||||
| "NO_SHOW"
|
||||
| "RESCHEDULED"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface PickupOffice {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
/** 0=Sunday .. 6=Saturday. */
|
||||
workingDays: number[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
slotDurationMinutes: number;
|
||||
maxApplicantsPerSlot: number;
|
||||
rescheduleMinNoticeHours: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PickupSlot {
|
||||
date: string;
|
||||
slotStartTime: string;
|
||||
capacity: number;
|
||||
booked: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface PickupAppointment {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
appointmentNumber: string;
|
||||
officeId: string;
|
||||
date: string;
|
||||
slotStartTime: string;
|
||||
status: PickupAppointmentStatus;
|
||||
rescheduledFromId: string | null;
|
||||
rescheduleCount: number;
|
||||
checkedInAt: string | null;
|
||||
checkedInById: string | null;
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
subject: Bilingual;
|
||||
@@ -479,6 +589,7 @@ export interface QueueFilter {
|
||||
licenseTypeId?: string;
|
||||
search?: string;
|
||||
status?: LicenseStatus[];
|
||||
kind?: ApplicationKind;
|
||||
/** Officer uuid, or the literal 'unassigned'. */
|
||||
assignee?: string;
|
||||
submittedFrom?: string;
|
||||
@@ -501,6 +612,15 @@ export type QueueSortField =
|
||||
/** Review → evaluation → (inspection) → approval, or the short registration course. */
|
||||
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
|
||||
|
||||
/** A permission to operate, or a registration granting a status and a number. */
|
||||
export type ServiceKind = "LICENSE" | "REGISTRATION";
|
||||
|
||||
/** Platform side effect fired when an application reaches COMPLETED. */
|
||||
export type CompletionEffect =
|
||||
| "REGISTER_SEAFARER"
|
||||
| "REGISTER_VESSEL"
|
||||
| "OPEN_SEAFARER_DOCUMENTS";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
unassigned: number;
|
||||
@@ -744,10 +864,40 @@ export interface IssuedLicense {
|
||||
* configuration.
|
||||
*/
|
||||
renewable?: boolean;
|
||||
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
|
||||
reissuable?: boolean;
|
||||
verificationCode: string;
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One personal document as the backoffice manages it: every configured row
|
||||
* sharing a key, which is one slot in the applicant's vault. Several rows mean
|
||||
* the document is scoped to several licence types.
|
||||
*/
|
||||
export interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
}
|
||||
|
||||
export interface PersonalDocumentFilter {
|
||||
/** Matches the key and the name in either locale. */
|
||||
search?: string;
|
||||
/** A licence type also matches the documents every licence asks for. */
|
||||
licenseTypeId?: string;
|
||||
/** Narrows to the documents configured against no licence type at all. */
|
||||
globalOnly?: boolean;
|
||||
sortBy?: 'sortOrder' | 'key' | 'name';
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
take?: number;
|
||||
skip?: number;
|
||||
/** Which locale `sortBy: "name"` sorts on. */
|
||||
locale?: 'en' | 'am';
|
||||
}
|
||||
|
||||
// No `EligibleExam`: it typed the schedule-exam picker's options, and
|
||||
// picking a sitting for a named applicant is not something the back office
|
||||
// does any more — candidates register for a published sitting themselves.
|
||||
|
||||
/**
|
||||
* Where a certificate application stands in the examination leg, derived
|
||||
|
||||
Reference in New Issue
Block a user