Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-31 13:08:21 +00:00
36 changed files with 3913 additions and 210 deletions

View File

@@ -21,6 +21,8 @@ import type {
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
CertificateCategory,
CompletionEffect,
DocumentDecision,
DocumentReview,
EligibleExam,
@@ -37,10 +39,15 @@ import type {
RemarkTargetType,
SavedQueueView,
SchemaIssue,
ServiceKind,
StaffRoleRequirement,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
WorkflowProfile,
} from './licensing.types';
/**
@@ -67,6 +74,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',
@@ -83,6 +100,10 @@ const TAGS = [
'PickupAppointment',
'Department',
'Rank',
'StaffRoleRequirement',
// 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;
@@ -127,9 +148,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'),
],
}),
/**
@@ -159,6 +188,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 }) => ({
@@ -170,6 +205,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,
@@ -240,9 +322,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')]),
@@ -265,6 +368,42 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// ----------------------------------------------- staff role requirements
/**
* Every staff role requirement, filtered by licence type at the call
* site — same reasoning as `getDocumentRequirements`: small
* configuration data with no pagination need.
*/
getStaffRoleRequirements: builder.query<Paginated<StaffRoleRequirement>, void>({
query: () => ({ url: '/staff-role-requirements' }),
providesTags: () => [listTag('StaffRoleRequirement')],
}),
createStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
Partial<StaffRoleRequirement> & { licenseTypeId: string; roleKey: string; name: StaffRoleRequirement['name'] }
>({
query: (body) => ({ url: '/staff-role-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
updateStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
{ id: string } & Partial<StaffRoleRequirement>
>({
query: ({ id, ...body }) => ({
url: `/staff-role-requirements/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
deleteStaffRoleRequirement: builder.mutation<unknown, string>({
query: (id) => ({ url: `/staff-role-requirements/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
@@ -423,6 +562,36 @@ export const licensingApi = baseApi
providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)],
}),
/**
* Copies the applicant's personal documents onto an application, for
* every requirement their vault answers and this application has not
* already got. Idempotent — a filled slot is never touched.
*/
fillApplicationDocumentsFromVault: builder.mutation<
{ filled: string[] },
string
>({
query: (applicationId) => ({
url: `/license-applications/${applicationId}/documents/fill-from-vault`,
method: 'POST',
}),
invalidatesTags: (_r, error, applicationId) =>
error ? [] : [itemTag('Attachment', applicationId)],
}),
/** The same, for a seafarer registration. */
fillRegistrationDocumentsFromVault: builder.mutation<
{ filled: string[] },
string
>({
query: (registrationId) => ({
url: `/seafarer-registrations/${registrationId}/documents/fill-from-vault`,
method: 'POST',
}),
invalidatesTags: (_r, error, registrationId) =>
error ? [] : [itemTag('Attachment', registrationId)],
}),
deleteAttachment: builder.mutation<unknown, { attachmentId: string; ownerId: string }>({
query: ({ attachmentId }) => ({
url: `/attachments/${attachmentId}`,
@@ -1210,13 +1379,19 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useGetPersonalDocumentsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetStaffRoleRequirementsQuery,
useCreateStaffRoleRequirementMutation,
useUpdateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,
@@ -1248,6 +1423,8 @@ export const {
useResolveRemarkMutation,
useResubmitApplicationMutation,
useGetAttachmentsQuery,
useFillApplicationDocumentsFromVaultMutation,
useFillRegistrationDocumentsFromVaultMutation,
useDeleteAttachmentMutation,
useGetQueueQuery,
useGetAssignedToMeQuery,

View File

@@ -400,6 +400,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 {

View File

@@ -109,6 +109,14 @@ export interface FormFieldConfig {
showWhen?: FieldCondition;
readOnly?: boolean;
source?: string;
/**
* Marks `min` as bound to live configuration rather than stored with the
* field — `"licenseType.capitalThreshold"` for a capital amount. The server
* resolves it on every read, so `min` already carries the real floor by the
* time the wizard sees it; this is only here so the builder round-trips the
* binding instead of dropping it on save.
*/
minSource?: string;
sortOrder?: number;
}
@@ -187,6 +195,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.
@@ -216,11 +229,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;
@@ -264,7 +305,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;
@@ -275,6 +321,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;
}
@@ -310,6 +364,7 @@ export interface StaffEvidenceRequirement {
export interface StaffRoleRequirement {
id: string;
licenseTypeId: string;
roleKey: string;
name: Bilingual;
minCount: number;
@@ -318,6 +373,7 @@ export interface StaffRoleRequirement {
minYearsExperience: number | null;
requiredEvidence: StaffEvidenceRequirement[];
sortOrder: number;
isActive: boolean;
}
export interface LicenseTypeRequirements {
@@ -387,6 +443,12 @@ export interface Attachment {
ownerType: string;
ownerId: string;
documentKey: string;
/**
* Set when this was filled from the applicant's personal document vault
* rather than uploaded here — what both apps badge as "From My Documents".
* Null for an ordinary upload, and cleared the moment the file is replaced.
*/
copiedFromAttachmentId?: string | null;
title: string | null;
validFrom: string | null;
validTo: string | null;
@@ -566,6 +628,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;
@@ -815,6 +886,31 @@ export interface IssuedLicense {
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';
}
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
export interface EligibleExam {
id: string;