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; } 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/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/features/license-review/pages/LicenseQueuePage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index 856b30581..f90008108 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 @@ -60,6 +60,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, ], ); @@ -763,7 +765,7 @@ export function LicenseQueuePage() { > {t("queue.export", "Export CSV")} - {isLogistics !== false && ( + {claimable && ( claimApplication(id).unwrap(), - t("review.done.claim", "Application claimed"), + t("review.done.claim", "Claimed — the application is now yours"), ); break; case "complete-review": diff --git a/apps/backoffice/src/app/features/license-review/queue-views.test.ts b/apps/backoffice/src/app/features/license-review/queue-views.test.ts index 1d423bce9..dbf349813 100644 --- a/apps/backoffice/src/app/features/license-review/queue-views.test.ts +++ b/apps/backoffice/src/app/features/license-review/queue-views.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { resolveFamilyKind } from "@ema-platform/api"; +import { hasUnclaimedPool, savedViewsForFamily } from "./queue-views"; describe("resolveFamilyKind", () => { 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..42e9d0eb3 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,39 @@ 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', + // 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? + * + * 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'); } 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..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,9 +32,17 @@ 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'; -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 ( @@ -74,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 = @@ -90,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(); @@ -132,21 +159,13 @@ export function EndorsementPage() { /> - - - - + {!registered && ( }> @@ -183,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. */} + + + )} + {/* 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. */} + + + )} + + ); + }, }, { 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..a2c2e32ec 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,15 @@ import { import { StatusBadge, AdvancedTable } from '@ema-platform/ui'; import { inFlightColumns } from '../inFlightColumns'; import { + extractErrorMessage, TERMINAL_STATUSES, useApiMutation, + useBypassPaymentMutation, useGetMyApplicationsQuery, } from '@ema-platform/api'; +import { notifications } from '@mantine/notifications'; import { authStorage } from '@ema-platform/auth'; +import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; // --------------------------------------------------------------------------- // Types @@ -132,6 +136,8 @@ export function VesselRegistrationPage() { const navigate = useNavigate(); const { t } = useTranslation(); const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery(); + const { pay, isPaying } = useApplicationPayment(); + const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const [page, setPage] = useState(0); const [registration, setRegistration] = useState(null); const [fetchTrigger] = useApiMutation(); @@ -155,9 +161,32 @@ 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, + bypassing, }); const certs = registration?.category === 'Sea-going Vessel (International)' diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 0ecee5cda..8b1efe359 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -63,7 +63,7 @@ export const am: Translations = { certificates: 'የምስክር ወረቀቶች', seamanBook: 'የመርከበኛ መጽሐፍ እና BTC', btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት', - endorsements: 'ማረጋገጫዎች', + endorsements: 'የባህረኛ ማስተያየት', vesselRegistrations: 'የመርከብ ምዝገባ', vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር', documents: 'ሰነዶቼ', @@ -1008,8 +1008,7 @@ export const am: Translations = { registered: "የተመዘገበ መርከበኛ ({{number}})", registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል", }, - endorseCoc: "CoC ያረጋግጡ", - endorseGoc: "GOC ያረጋግጡ", + apply: "ለማስተያየት ያመልክቱ", registrationNotice: { prefix: "መጀመሪያ የ", link: "መርከበኛ ምዝገባዎን", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 57fe394f3..cc50ad5c1 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -63,7 +63,7 @@ export const en = { certificates: 'Certificates', seamanBook: 'SeamanBook and BTC', btc: 'Basic Training Certificate', - endorsements: 'Endorsements', + endorsements: 'Endorsement Seafarer', vesselRegistrations: 'Vessel Registration', vesselTransfers: 'Vessel Transfers', documents: 'My Documents', @@ -1010,8 +1010,7 @@ export const en = { registered: 'Registered seafarer ({{number}})', registrationRequired: 'Active seafarer registration required', }, - endorseCoc: 'Endorse a CoC', - endorseGoc: 'Endorse a GOC', + apply: 'Apply for an endorsement', registrationNotice: { prefix: 'Complete your', link: 'seafarer registration', diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 544fe01a9..c6ffb7fb4 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -140,7 +140,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [ }, { to: "/endorsements", - label: "Endorsements", + label: "Endorsement Seafarer", i18nKey: "nav.endorsements", icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES], diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index ecb0218d4..8f3cff394 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session'; import type { Bilingual, FamilyKind, + FieldCondition, FormFieldConfig, FormSectionConfig, LicenseApplication, @@ -179,6 +180,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [ 'CERTIFICATE_OF_PROFICIENCY', 'VESSEL_REGISTRATION', 'VESSEL_OWNERSHIP_TRANSFER', + 'ENDORSEMENT_SEAFARER', 'ENDORSEMENT_COC', 'ENDORSEMENT_GOC', ]; @@ -190,6 +192,7 @@ const FAMILY_KIND_BY_KEY: Partial> = { 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..0be26f0ab 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -74,9 +74,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[]; }