From 13c3e8974d112e607e29901661fbe47fe0802624 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 09:39:24 +0000 Subject: [PATCH] feat: consolidate CoC and GOC endorsement workflows into a new ENDORSEMENT_SEAFARER type and add anyOf support to conditional form field logic. --- .../license-review/config/license-types.ts | 14 +++++++-- apps/backoffice/src/app/i18n/locales/am.ts | 4 +-- apps/backoffice/src/app/i18n/locales/en.ts | 4 +-- apps/backoffice/src/app/layouts/nav-config.ts | 3 +- apps/backoffice/src/app/router/index.tsx | 6 +++- .../endorsement/pages/EndorsementPage.tsx | 31 +++++++++---------- .../components/RequireOperations.tsx | 4 +++ .../pages/OperationsOnboardingPage.tsx | 6 ++++ .../components/OperationsFormContent.tsx | 11 +++++-- apps/portal/src/app/i18n/locales/am.ts | 5 ++- apps/portal/src/app/i18n/locales/en.ts | 5 ++- apps/portal/src/app/layouts/PortalLayout.tsx | 2 +- .../features/licensing/licensing.helpers.ts | 14 +++++++-- .../lib/features/licensing/licensing.types.ts | 8 +++-- 14 files changed, 78 insertions(+), 39 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/config/license-types.ts b/apps/backoffice/src/app/features/license-review/config/license-types.ts index 379d74c28..7e920efa5 100644 --- a/apps/backoffice/src/app/features/license-review/config/license-types.ts +++ b/apps/backoffice/src/app/features/license-review/config/license-types.ts @@ -116,11 +116,21 @@ const PRESENTATION: Record = { // no capital threshold, no staff roles. detailSections: ['overview', 'documents'], }, + ENDORSEMENT_SEAFARER: { + key: 'ENDORSEMENT_SEAFARER', + icon: IconRubberStamp, + // Person-centric, same as seafarer registration: no company entity, no + // capital threshold, no staff roles, no inspection. Covers CoC and GOC + // together — the `endorsementScope` field on the application decides + // which certificate section(s) actually have data. + detailSections: ['overview', 'documents'], + }, + // Retired by ENDORSEMENT_SEAFARER (see endorsements.seed-data.ts). Kept so + // an application filed before the switch still renders with the right + // presentation instead of falling back to the generic company layout. ENDORSEMENT_COC: { key: 'ENDORSEMENT_COC', icon: IconRubberStamp, - // Person-centric, same as seafarer registration: no company entity, no - // capital threshold, no staff roles, no inspection. detailSections: ['overview', 'documents'], }, ENDORSEMENT_GOC: { diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 458b638e6..c4d5459b3 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -48,6 +48,7 @@ export const am: Translations = { typeVESSEL_OWNERSHIP_TRANSFER: "የመርከብ ባለቤትነት ዝውውር", typeCERTIFICATE_OF_COMPETENCY: "የብቃት ማረጋገጫ ምስክር ወረቀት", typeCERTIFICATE_OF_PROFICIENCY: "የብቃት ምስክር ወረቀት", + typeENDORSEMENT_SEAFARER: "የባህረኛ ማስተያየት (CoC / GOC)", typeENDORSEMENT_COC: "የCoC ማረጋገጫ", typeENDORSEMENT_GOC: "የGOC ማረጋገጫ", primary: "ዋና", @@ -87,8 +88,7 @@ export const am: Translations = { btcQueue: "የBTC ወረፋ", cocQueue: "የCoC ወረፋ", copQueue: "የCoP ወረፋ", - endorsementCocQueue: "የCoC ማረጋገጫ ወረፋ", - endorsementGocQueue: "የGOC ማረጋገጫ ወረፋ", + endorsementQueue: "የማስተያየት ወረፋ", vesselRegistrations: "የመርከብ ምዝገባ", // vesselRegistrationReport: 'የምዝገባ ሪፖርት', vesselTransfers: "የመርከብ ባለቤትነት ዝውውር", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 73a9d96d7..3e8ff4f59 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -48,6 +48,7 @@ export const en = { typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer', typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency', typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency', + typeENDORSEMENT_SEAFARER: 'Seafarer Endorsement (CoC / GOC)', typeENDORSEMENT_COC: 'CoC Endorsement', typeENDORSEMENT_GOC: 'GOC Endorsement', primary: 'Primary', @@ -87,8 +88,7 @@ export const en = { postWaiverQueue: 'Post-Waiver Queue', cocQueue: 'CoC Queue', copQueue: 'CoP Queue', - endorsementCocQueue: 'CoC Endorsement Queue', - endorsementGocQueue: 'GOC Endorsement Queue', + endorsementQueue: 'Endorsement Queue', vesselRegistrations: 'Vessel Registration', vesselTransfers: 'Vessel Ownership Transfer', seafarerRegistry: 'Seafarer Registry', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index 3878efa3f..ef8690640 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -101,8 +101,7 @@ export const NAV_SECTIONS: NavSection[] = [ { to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE }, { to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, - { to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, + { to: '/licence-review/type/ENDORSEMENT_SEAFARER', label: 'nav.endorsementQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE }, { to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] }, ], diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 46135bab4..dde6d1e2e 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -90,7 +90,11 @@ const router = createBrowserRouter([ { path: 'coc-queue', element: }, { path: 'coc-queue/:id', element: }, // Endorsement review happens in the config-driven licence queue. - { path: 'endorsement-queue', element: }, + // CoC and GOC endorsement now share one combined type; the two old + // type queues (ENDORSEMENT_COC / ENDORSEMENT_GOC) are left routing + // through the generic `:typeCode` queue below rather than redirected, + // so an application filed before the switch stays reachable there. + { path: 'endorsement-queue', element: }, { path: 'endorsement-queue/:id', element: }, { path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx index dda909899..a74a54be7 100644 --- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx +++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx @@ -33,7 +33,14 @@ import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform import { useDateDisplayer } from '@ema-platform/shared'; import { endorsementColumns } from './columns'; -const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC']; +// ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new +// applications file against; the other two stay listed so an application or +// licence filed before the switch keeps showing up here. +const ENDORSEMENT_TYPE_KEYS = [ + 'ENDORSEMENT_SEAFARER', + 'ENDORSEMENT_COC', + 'ENDORSEMENT_GOC', +]; function EligibilityItem({ ok, label }: { ok: boolean; label: string }) { return ( @@ -132,21 +139,13 @@ export function EndorsementPage() { /> - - - - + {!registered && ( }> diff --git a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx index c50ef617d..750826a8d 100644 --- a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx +++ b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx @@ -55,6 +55,10 @@ const MODE_FREE_TYPE_KEYS = [ 'VESSEL_OWNERSHIP_TRANSFER', 'CERTIFICATE_OF_COMPETENCY', 'CERTIFICATE_OF_PROFICIENCY', + 'ENDORSEMENT_SEAFARER', + // Retired by ENDORSEMENT_SEAFARER but kept mode-free: an in-flight + // application filed against one of these before the switch must still be + // reachable to view, correct or resubmit. 'ENDORSEMENT_COC', 'ENDORSEMENT_GOC', 'PRE_WAIVER', diff --git a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx index 8499b8e4f..644d9b505 100644 --- a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx +++ b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx @@ -16,10 +16,16 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo * Seafarer goes to its own registration page, whose Identity Details step * collects the profile answers itself — no detour via `/profile`. Seafarer * wins when both are ticked; the other form is one nav click away. + * + * SEAFARER_REGISTRATION is listed before ENDORSEMENT_SEAFARER deliberately: + * `nextStepFor` takes the first key that matches, and an applicant who ticked + * both belongs in registration first — the endorsement application refuses + * submission until that registration is accepted. */ const NEXT_STEP: Record = { SEAFARER_REGISTRATION: "/seafarer-registration", VESSEL_REGISTRATION: "/vessel-registration", + ENDORSEMENT_SEAFARER: "/endorsements", }; function nextStepFor(selectedKeys: string[]): string { diff --git a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx index 4d1babe08..34a3aae65 100644 --- a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx @@ -25,15 +25,21 @@ import { notify, ModalFooter } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; /** - * Registrations an applicant makes for themselves rather than for a company. + * Registrations and seafarer-only services an applicant declares for + * themselves rather than for a company. * * Named explicitly rather than inferred from `requiresOperatorMode: false`, * because that flag is also false for things nobody declares up front — a * waiver is requested per shipment, not adopted as an identity. + * ENDORSEMENT_SEAFARER belongs here for the same reason as the two + * registrations: a seafarer requesting one is not declaring a logistics mode + * of operation, and without this key `accountTypeFor` below would fall the + * account through to the company-representative type instead of `SEAFARER`. */ const PERSONAL_REGISTRATION_KEYS = [ 'SEAFARER_REGISTRATION', 'VESSEL_REGISTRATION', + 'ENDORSEMENT_SEAFARER', ]; /** @@ -56,6 +62,7 @@ function accountTypeFor(keys: string[]): string | null { } if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER'; if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER'; + if (keys.includes('ENDORSEMENT_SEAFARER')) return 'SEAFARER'; return null; } @@ -212,7 +219,7 @@ export function OperationsFormContent({ {personalOptions.length > 0 && ( <> - Registering as an individual or vessel owner + Registering or applying as an individual or vessel owner {personalOptions.map((type) => ( > = { BTC_BASIC_TRAINING: 'CERTIFICATE', CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE', CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE', + ENDORSEMENT_SEAFARER: 'CERTIFICATE', ENDORSEMENT_COC: 'CERTIFICATE', ENDORSEMENT_GOC: 'CERTIFICATE', FREIGHT_FORWARDER: 'LOGISTICS_LICENSE', @@ -557,7 +560,14 @@ export function validateSections( return errors; } -/** Evaluates a config condition against the current form answers. */ +/** + * Evaluates a config condition against the current form answers. + * + * Mirrors the server's `ApplicationValidationService.conditionHolds` — + * `anyOf` holds when any listed sub-condition holds, needed for an answer + * that can live on one of several mutually-exclusive fields (e.g. a CoP rank + * split by department). + */ interface ConditionLike { field?: string; equals?: unknown; @@ -569,7 +579,7 @@ interface ConditionLike { } export function conditionHolds( - condition: ConditionLike | undefined | null, + condition: FieldCondition | undefined | null, formData: Record>, ): boolean { if (!condition) return true; diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 57a0919c2..a89fc0e06 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -67,6 +67,8 @@ export type FormFieldType = | "TIN"; export interface FieldCondition { + /** Omitted when `anyOf` is used instead — see below. */ + field?: string; /** Omitted when `anyOf` is used instead — see below. */ field?: string; equals?: string | number | boolean; @@ -74,9 +76,9 @@ export interface FieldCondition { in?: (string | number)[]; isSet?: boolean; /** - * Holds when ANY listed condition holds — for a value that can live on one - * of several mutually-exclusive fields (e.g. a rank split by department). - * `field`/`equals`/etc are ignored when this is present. + * Alternative to a single-field check: holds when ANY listed condition + * holds. `field`/`equals`/etc are ignored when this is present. Mirrors + * the server's `FieldCondition` (form-schema.type.ts). */ anyOf?: FieldCondition[]; }