From b458bae35cc75322133d31f3b651c03f04f9a338 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 05:52:56 +0000 Subject: [PATCH 01/11] refactor: introduce hasUnclaimedPool helper to allow claim workflows for specific document-family queues --- .../pages/LicenseQueuePage/index.tsx | 34 ++++++++++--------- .../license-review/queue-views.test.ts | 26 ++++++++++++++ .../features/license-review/queue-views.ts | 32 ++++++++++++++--- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index b8791eb4e..ed3a5576c 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -61,6 +61,7 @@ import { SAVED_VIEWS, filterFromSearchParams, readLastView, + hasUnclaimedPool, savedViewsForFamily, searchParamsFromFilter, writeLastView, @@ -159,12 +160,16 @@ export function LicenseQueuePage() { const isLogistics = typeCode ? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE" : undefined; - const visibleViews = savedViewsForFamily(isLogistics !== false); + // Claiming is a workflow property, not a label one — Vessel Registration is + // a DOCUMENT family but is still triaged off a shared unclaimed pool, so it + // keeps the Unassigned tab and the claim actions. + const claimable = hasUnclaimedPool(typeCode); + const visibleViews = savedViewsForFamily(claimable); const [view, setView] = useState( () => (searchParams.get("view") as SavedViewId) || - (isLogistics === false ? "all" : readLastView()), + (claimable ? readLastView() : "all"), ); const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1); const [pageSize, setPageSize] = useState(PAGE_SIZE); @@ -174,21 +179,17 @@ export function LicenseQueuePage() { const [helpOpen, setHelpOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); - // Non-logistics queues have no unassigned/unclaimed pool (see - // `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from + // Queues with no unclaimed pool (see `hasUnclaimedPool`) have no unassigned + // tab, so a stale "unassigned" view — e.g. restored from // `readLastView()` — must fall back to "all" rather than land on a tab that // no longer exists. Auto-created BTC requests specifically start at // PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed // to show them. useEffect(() => { - if ( - isLogistics === false && - !searchParams.has("view") && - view === "unassigned" - ) { + if (!claimable && !searchParams.has("view") && view === "unassigned") { setView("all"); } - }, [isLogistics, searchParams, view]); + }, [claimable, searchParams, view]); const urlFilter = useMemo( () => filterFromSearchParams(searchParams), @@ -414,9 +415,9 @@ export function LicenseQueuePage() { onPrevious: () => setCursor((c) => Math.max(c - 1, 0)), onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`), onClaim: () => { - // Only unclaimed rows on a logistics queue can be claimed; pressing c + // Only unclaimed rows on a claimable queue can be claimed; pressing c // elsewhere is a no-op rather than an error the officer has to read. - if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null) + if (claimable && cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id); }, onEscape: () => setSelected([]), @@ -481,9 +482,9 @@ export function LicenseQueuePage() { claiming, onClaim: handleClaim, onOpen: (id) => navigate(`/licence-review/${id}`), - // Non-logistics applications aren't claimed off a shared queue (see - // `savedViewsForFamily`) — every row opens straight to Review. - claimable: isLogistics !== false, + // Applications with no unclaimed pool (see `hasUnclaimedPool`) are + // never claimed — every row opens straight to Review. + claimable, }), ], [ @@ -496,6 +497,7 @@ export function LicenseQueuePage() { items, claiming, isLogistics, + claimable, ], ); @@ -767,7 +769,7 @@ export function LicenseQueuePage() { > {t("queue.export", "Export CSV")} - {isLogistics !== false && ( + {claimable && ( { it("treats person-centric seafarer applications as document queues", () => { @@ -8,3 +9,28 @@ describe("resolveFamilyKind", () => { expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE"); }); }); + +describe("hasUnclaimedPool", () => { + it("keeps the unassigned pool for logistics licences", () => { + expect(hasUnclaimedPool("FREIGHT_FORWARDER")).toBe(true); + }); + + it("keeps it for vessel registration, a DOCUMENT queue that is still claimed", () => { + expect(resolveFamilyKind("VESSEL_REGISTRATION")).toBe("DOCUMENT"); + expect(hasUnclaimedPool("VESSEL_REGISTRATION")).toBe(true); + expect(savedViewsForFamily(hasUnclaimedPool("VESSEL_REGISTRATION")).map((v) => v.id)).toContain( + "unassigned", + ); + }); + + it("drops it for the person-centric services", () => { + expect(hasUnclaimedPool("SEAMAN_BOOK")).toBe(false); + expect(savedViewsForFamily(hasUnclaimedPool("SEAMAN_BOOK")).map((v) => v.id)).not.toContain( + "unassigned", + ); + }); + + it("keeps it for the mixed All/Mine grids, where no type is pinned", () => { + expect(hasUnclaimedPool(undefined)).toBe(true); + }); +}); diff --git a/apps/backoffice/src/app/features/license-review/queue-views.ts b/apps/backoffice/src/app/features/license-review/queue-views.ts index 6689b73a0..6b167779b 100644 --- a/apps/backoffice/src/app/features/license-review/queue-views.ts +++ b/apps/backoffice/src/app/features/license-review/queue-views.ts @@ -1,3 +1,4 @@ +import { resolveFamilyKind } from '@ema-platform/api'; import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api'; export type SavedViewId = @@ -78,12 +79,33 @@ export const SAVED_VIEWS: SavedView[] = [ export const DEFAULT_VIEW: SavedViewId = 'unassigned'; /** - * Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...) - * have no unclaimed pool to triage — those applications aren't claimed off a - * shared queue — so the tab that lists it doesn't apply there. + * Type keys reviewed off a shared unclaimed pool despite not being logistics + * licences. Whether a queue is claimed is an officer-workflow property, not a + * label one: vessel registrations arrive unassigned and officers claim them, + * even though the family kind is DOCUMENT because the certificate they produce + * is a document rather than a licence. */ -export function savedViewsForFamily(isLogistics: boolean): SavedView[] { - return isLogistics +const CLAIMABLE_NON_LOGISTICS = new Set(['VESSEL_REGISTRATION']); + +/** + * Does this queue have an unclaimed pool to triage? + * + * True for the mixed All/Mine grids (no type pinned) — nothing is hidden when + * the queue spans every type. False for the person-centric services (Seafarer + * Registration, Seaman Book, BTC, CoC, ...), whose applications aren't claimed + * off a shared queue, so the tab and the claim actions don't apply there. + */ +export function hasUnclaimedPool(typeCode: string | undefined): boolean { + if (!typeCode) return true; + return ( + resolveFamilyKind(typeCode) === 'LOGISTICS_LICENSE' || + CLAIMABLE_NON_LOGISTICS.has(typeCode) + ); +} + +/** Drops the Unassigned tab on queues with no unclaimed pool. */ +export function savedViewsForFamily(claimable: boolean): SavedView[] { + return claimable ? SAVED_VIEWS : SAVED_VIEWS.filter((v) => v.id !== 'unassigned'); } From 841579468a4665c39ba55393ae45e64349f0f4f2 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 06:09:44 +0000 Subject: [PATCH 02/11] feat: add claim application functionality to the license review page --- .../license-review/pages/LicenseReviewPage/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index c26ec2cc8..166b512eb 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -533,11 +533,11 @@ export function LicenseReviewPage() { try { switch (action.id) { case "claim": - // Usually fired from the queue, but an officer can also open an - // unclaimed application directly and claim it from here. + // Usually fired from the queue, but an officer who opened an + // unclaimed application directly claims it from here. await run( () => claimApplication(id).unwrap(), - t("review.done.claim", "Application claimed"), + t("review.done.claim", "Claimed — the application is now yours"), ); break; case "complete-review": From ae0dfdece36b9e738098dc0e8a62eda1d14a7c79 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 06:57:22 +0000 Subject: [PATCH 03/11] refactor: update vessel registration route path in onboarding navigation --- .../app/features/onboarding/pages/OperationsOnboardingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx index 34d98dc83..8499b8e4f 100644 --- a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx +++ b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx @@ -19,7 +19,7 @@ import { OperationsFormContent } from "../../profile/components/OperationsFormCo */ const NEXT_STEP: Record = { SEAFARER_REGISTRATION: "/seafarer-registration", - VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply", + VESSEL_REGISTRATION: "/vessel-registration", }; function nextStepFor(selectedKeys: string[]): string { From 3d6deb03f6561cae4b2d9abb4085d69765b8ffe4 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 07:58:08 +0000 Subject: [PATCH 04/11] feat: add payment and bypass actions to the in-flight vessel registration application table --- .../vessel-registration/inFlightColumns.tsx | 61 ++++++++++++++++--- .../pages/VesselRegistrationPage.tsx | 32 ++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx b/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx index 43dc3e382..c03cbe472 100644 --- a/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx +++ b/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx @@ -12,7 +12,14 @@ import { /** Columns for the applicant's in-flight vessel registration applications. */ export function inFlightColumns( t: TFunction, - deps: { onOpen: (app: LicenseApplication) => void }, + deps: { + onOpen: (app: LicenseApplication) => void; + onPay: (app: LicenseApplication) => void; + onBypass: (app: LicenseApplication) => void; + isPaying: boolean; + bypassEnabled: boolean; + bypassing: boolean; + }, ): AdvancedColumn[] { return [ { @@ -38,15 +45,49 @@ export function inFlightColumns( }, { header: t('applications.table.progress'), - size: 140, - cell: ({ row }) => ( - - ), + size: 300, + cell: ({ row }) => { + const app = row.original; + return ( + + + {/* The fee stops the registration dead, so the payment action sits + on the bar rather than being hidden behind View. */} + {app.status === 'PAYMENT_PENDING' && ( + <> + + {deps.bypassEnabled && ( + + )} + + )} + + ); + }, }, { header: '', diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx index 7f896900b..4bf751431 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx @@ -30,11 +30,16 @@ import { import { StatusBadge, AdvancedTable } from '@ema-platform/ui'; import { inFlightColumns } from '../inFlightColumns'; import { + extractErrorMessage, TERMINAL_STATUSES, useApiMutation, + useBypassPaymentMutation, useGetMyApplicationsQuery, + useGetPaymentCapabilitiesQuery, } from '@ema-platform/api'; +import { notifications } from '@mantine/notifications'; import { authStorage } from '@ema-platform/auth'; +import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; // --------------------------------------------------------------------------- // Types @@ -132,6 +137,9 @@ export function VesselRegistrationPage() { const navigate = useNavigate(); const { t } = useTranslation(); const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery(); + const { pay, isPaying } = useApplicationPayment(); + const { data: capabilities } = useGetPaymentCapabilitiesQuery(); + const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const [page, setPage] = useState(0); const [registration, setRegistration] = useState(null); const [fetchTrigger] = useApiMutation(); @@ -155,9 +163,33 @@ export function VesselRegistrationPage() { !TERMINAL_STATUSES.includes(app.status), ); + async function handleBypass(applicationId: string) { + try { + const result = await bypassPayment(applicationId).unwrap(); + notifications.show({ + color: 'teal', + title: 'Payment bypassed', + message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`, + }); + refetch(); + } catch (err) { + notifications.show({ + color: 'red', + title: 'Bypass failed', + message: extractErrorMessage(err), + }); + } + } + const columns = inFlightColumns(t, { onOpen: (app) => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`), + // Paying leaves the SPA for Telebirr — a provider hand-off, not a route change. + onPay: (app) => pay(app.id), + onBypass: (app) => handleBypass(app.id), + isPaying, + bypassEnabled: capabilities?.bypassEnabled ?? false, + bypassing, }); const certs = registration?.category === 'Sea-going Vessel (International)' From 573b2034225b459e75e06188b94011254435887b Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 22 Aug 2026 08:01:18 +0000 Subject: [PATCH 05/11] refactor: remove bypass payment capability check and enable bypass button unconditionally --- .../vessel-registration/inFlightColumns.tsx | 24 +++++++++---------- .../pages/VesselRegistrationPage.tsx | 3 --- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx b/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx index c03cbe472..88e5c18b4 100644 --- a/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx +++ b/apps/portal/src/app/features/vessel-registration/inFlightColumns.tsx @@ -17,7 +17,6 @@ export function inFlightColumns( onPay: (app: LicenseApplication) => void; onBypass: (app: LicenseApplication) => void; isPaying: boolean; - bypassEnabled: boolean; bypassing: boolean; }, ): AdvancedColumn[] { @@ -72,17 +71,18 @@ export function inFlightColumns( currency: app.feeCurrency, })} - {deps.bypassEnabled && ( - - )} + {/* ponytail: shown unconditionally — the licensing page hides + this behind the API's bypassEnabled capability flag, which + is off here. Re-gate on capabilities before prod. */} + )} diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx index 4bf751431..a2c2e32ec 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx @@ -35,7 +35,6 @@ import { useApiMutation, useBypassPaymentMutation, useGetMyApplicationsQuery, - useGetPaymentCapabilitiesQuery, } from '@ema-platform/api'; import { notifications } from '@mantine/notifications'; import { authStorage } from '@ema-platform/auth'; @@ -138,7 +137,6 @@ export function VesselRegistrationPage() { const { t } = useTranslation(); const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery(); const { pay, isPaying } = useApplicationPayment(); - const { data: capabilities } = useGetPaymentCapabilitiesQuery(); const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const [page, setPage] = useState(0); const [registration, setRegistration] = useState(null); @@ -188,7 +186,6 @@ export function VesselRegistrationPage() { onPay: (app) => pay(app.id), onBypass: (app) => handleBypass(app.id), isPaying, - bypassEnabled: capabilities?.bypassEnabled ?? false, bypassing, }); From 13c3e8974d112e607e29901661fbe47fe0802624 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 09:39:24 +0000 Subject: [PATCH 06/11] 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[]; } From 3cab22b58da431bb2d5773b9150f06d17d24745e Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 09:41:08 +0000 Subject: [PATCH 07/11] fix: update conditional requirement validation to allow expressions using anyOf without a field --- .../components/DocumentRequirementEditorDrawer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx index 5baba8e99..1efa584e7 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -94,10 +94,16 @@ export function DocumentRequirementEditorDrawer({ return; } if (!draft.name.en?.trim()) return; + // A condition is either a single-field check or an anyOf list — the CoP + // watch_rating_certificate requirement is seeded with anyOf and no field. const hasCondition = Boolean(draft.conditionExpression?.field) || Boolean(draft.conditionExpression?.anyOf?.length); - if (draft.mode === 'CONDITIONAL' && !hasCondition) { + if ( + draft.mode === 'CONDITIONAL' && + !hasCondition && + !draft.conditionExpression?.anyOf?.length + ) { setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); return; } From b0998703702dd924d1a785972b759fe9db1e3c9a Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 09:52:31 +0000 Subject: [PATCH 08/11] fix: prevent out-of-bounds wizard steps and remove duplicate FieldCondition property definition --- .../features/licensing/pages/LicenseApplicationPage.tsx | 8 ++++++++ libs/api/src/lib/features/licensing/licensing.types.ts | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 73728c62e..a7b4f92ac 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -356,6 +356,14 @@ export function LicenseApplicationPage() { [steps], ); + // A section-level showWhen can remove a step while the wizard is open + // (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope). + // Clamp so `steps[active]` can never go out of bounds if a seed ever lets + // a later answer hide an earlier step. + useEffect(() => { + if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1)); + }, [active, steps.length]); + if (loadingConfig || !config || !appId || !application) { return ; } diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index a89fc0e06..0be26f0ab 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -67,8 +67,6 @@ 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; From 32ac18adc29639abf05c269cb409c493380bc734 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 11:50:49 +0000 Subject: [PATCH 09/11] endorcement --- .../seafarer-registration/pages/SeafarerRegistrationPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx index 838bc1e77..ad229976d 100644 --- a/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer-registration/pages/SeafarerRegistrationPage.tsx @@ -373,7 +373,7 @@ export function SeafarerRegistrationPage() { {registration.status === 'APPROVED' && ( } title="Registered" mb="md"> You are a registered seafarer. Your seafarer number is {registration.seafarerNumber}. - Your Seaman Book and Basic Training Certificate applications have been opened for you. + You can now apply for a certificate endorsement from the Endorsement Seafarer page. )} {registration.status === 'REJECTED' && ( From f20ed670de13932e3a4580856a378c81ecf97b37 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 12:12:30 +0000 Subject: [PATCH 10/11] fixes on backoffice for endorcement --- .../app/features/license-review/config/actions.ts | 12 ++++++++++++ .../src/app/features/license-review/queue-views.ts | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 2ce63ea65..6a5bf7118 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -358,6 +358,18 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return []; if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return []; + // The transition table doesn't know which types need an inspection, so + // `availableEvents` lists approve-documents at UNDER_EVALUATION even for + // types without one — where the server refuses it + // (`inspection_not_required_use_final_approve`). Final Approve is the + // real action there; don't render its dead twin. + if ( + action.id === 'approve-documents' && + !app.licenseType?.inspectionRequired + ) { + return []; + } + const disabled = (reason: string): ResolvedAction => ({ ...action, enabled: false, diff --git a/apps/backoffice/src/app/features/license-review/queue-views.ts b/apps/backoffice/src/app/features/license-review/queue-views.ts index 6b167779b..42e9d0eb3 100644 --- a/apps/backoffice/src/app/features/license-review/queue-views.ts +++ b/apps/backoffice/src/app/features/license-review/queue-views.ts @@ -85,7 +85,13 @@ export const DEFAULT_VIEW: SavedViewId = 'unassigned'; * even though the family kind is DOCUMENT because the certificate they produce * is a document rather than a licence. */ -const CLAIMABLE_NON_LOGISTICS = new Set(['VESSEL_REGISTRATION']); +const CLAIMABLE_NON_LOGISTICS = new Set([ + 'VESSEL_REGISTRATION', + // Endorsements run the standard claim-first workflow: applications arrive + // unassigned and an officer claims them, even though the family kind is + // CERTIFICATE. + 'ENDORSEMENT_SEAFARER', +]); /** * Does this queue have an unclaimed pool to triage? From 3955dab014f31a2218c8061890553b9d66e3290b Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 26 Aug 2026 12:17:46 +0000 Subject: [PATCH 11/11] adding bypass payment --- .../endorsement/pages/EndorsementPage.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx index a74a54be7..c6ce7252d 100644 --- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx +++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx @@ -24,6 +24,7 @@ import { TERMINAL_STATUSES, extractErrorMessage, useLocalized, + useBypassPaymentMutation, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, @@ -31,6 +32,7 @@ import { import { useCurrentProfile } from '@ema-platform/auth'; import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; import { endorsementColumns } from './columns'; // ENDORSEMENT_SEAFARER covers CoC and GOC together and is the only type new @@ -81,6 +83,8 @@ export function EndorsementPage() { const showDate = useDateDisplayer(); const localized = useLocalized(); const [getCertificateUrl] = useGetCertificateUrlMutation(); + const { pay, isPaying } = useApplicationPayment(); + const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const issuedTable = useServerTable(); const registered = @@ -97,6 +101,22 @@ export function EndorsementPage() { ); const issuedPage = issuedTable.paginate(issued); + async function handleBypass(applicationId: string) { + try { + const result = await bypassPayment(applicationId).unwrap(); + notify.success( + result.certificateIssued + ? t('endorsement.bypassIssued', 'Payment bypassed — the endorsement has been issued.') + : t('endorsement.bypassOk', { + defaultValue: 'Payment bypassed — application is now {{status}}.', + status: result.status.replace(/_/g, ' ').toLowerCase(), + }), + ); + } catch (err) { + notify.error(extractErrorMessage(err, t('endorsement.bypassFailed', 'Bypass failed'))); + } + } + async function download(licenseId: string) { try { const result = await getCertificateUrl(licenseId).unwrap(); @@ -182,6 +202,36 @@ export function EndorsementPage() { {t(`applications.status.${app.status}`, STATUS_LABELS[app.status])} + {app.status === 'PAYMENT_PENDING' && ( + <> + + {/* ponytail: shown unconditionally for the testing + phase — the server still refuses it unless + ALLOW_PAYMENT_BYPASS is set and NODE_ENV is not + production. Re-gate on useGetPaymentCapabilitiesQuery + (like MyApplicationsPage) before prod. */} + + + )}