diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx index 41ce93dd3..7fff5a99a 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -129,7 +129,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { // against an expiry that never arrives, so none of them are asked for. const expires = draft.validityDays !== null || draft.validityMonths > 0; - // The server's floor for a stated term is 6 months, so no-expiry is a state + // The server's floor for a stated term is 1 month, so no-expiry is a state // this form can hold and edit around but cannot switch a type into. Offered // only where it is already what the type is, rather than as an option whose // save would be refused. @@ -140,8 +140,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { // Only the settings this form actually asked for. The endpoint patches, so // an omitted field keeps its stored value — and the fields hidden above are // hidden precisely because the type has no such policy, which the server - // stores as a zero its own validators then refuse (`validityMonths` has a - // floor of 6, `renewalWindowDays` of 1). Echoing those back is what made + // stores as a zero its own validators then refuse (`validityMonths` and + // `renewalWindowDays` both have a floor of 1). Echoing those back is what made // saving an ownership transfer fail outright. const { validityMonths, @@ -152,7 +152,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { } = draft; // A type switched from "does not expire" straight to a term in days still - // carries `validityMonths: 0`, which the server's 6-month floor refuses. + // carries `validityMonths: 0`, which the server's 1-month floor refuses. // Days win over months at issuance, so the zero is simply left alone. const term = expires ? { @@ -345,8 +345,8 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { else set('validityMonths', next); }} // Matches the server's ranges, so the box cannot offer a value the - // save would reject: 1–3650 days, or 6–240 months. - min={draft.validityDays !== null ? 1 : 6} + // save would reject: 1–3650 days, or 1–240 months. + min={1} max={draft.validityDays !== null ? 3650 : 240} allowNegative={false} disabled={!canEdit} @@ -388,7 +388,7 @@ export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { patch({ validityDays: null, validityMonths: - draft.validityMonths >= 6 ? draft.validityMonths : 12, + draft.validityMonths >= 1 ? draft.validityMonths : 12, }); // No expiry means no renewal policy: clear it here rather than // save renewal settings that could never fire. diff --git a/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx b/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx index 75003dbec..d14823312 100644 --- a/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx +++ b/apps/backoffice/src/app/features/configuration/components/LicenseTypesTab.tsx @@ -168,7 +168,7 @@ export function LicenseTypesTab() { }, feeCurrency: (v) => (v.trim().length > 8 ? t('configuration.licenseTypes.validation.currencyTooLong', 'Use a short currency code') : null), validityMonths: (v) => - v >= 6 && v <= 240 ? null : t('configuration.licenseTypes.validation.validityRange', 'Validity must be between 6 and 240 months'), + v >= 1 && v <= 240 ? null : t('configuration.licenseTypes.validation.validityRange', 'Validity must be between 1 and 240 months'), }, }); @@ -462,7 +462,7 @@ export function LicenseTypesTab() { true, reasons: REASONS, @@ -92,6 +98,28 @@ describe("resolveActions — seafarer certificates skip the queue", () => { expect(adjust?.enabled).toBe(false); expect(adjust?.disabledReason).toBe(REASONS.notAssigned); }); + + // The Behaviour tab's `certificateCategory` decides, not the key: a type an + // administrator configured as a CoC skips the queue whatever it is called, + // and a type they cleared the category on is queued like any licence. + it("skips the queue for a type configured as a CoC under any key", () => { + const ids = resolve("MASTER_MARINER", "COC").map((a) => a.id); + expect(ids).not.toContain("claim"); + expect(ids).not.toContain("assign"); + }); + + it("queues a certificate-looking key whose category was cleared", () => { + const actions = resolve("COC_MASTER", null); + expect(actions.map((a) => a.id)).toContain("claim"); + const adjust = actions.find((a) => a.id === "request-adjustment"); + expect(adjust?.enabled).toBe(false); + }); + + it("keeps endorsements in the queue", () => { + expect(resolve("ENDORSEMENT_SEAFARER", "ENDORSEMENT").map((a) => a.id)).toContain( + "claim", + ); + }); }); /** @@ -119,3 +147,84 @@ describe("resolveActions — approval waits on a passed inspection", () => { expect(inspected(true)?.enabled).toBe(true); }); }); + +/** + * A vessel registration is gated on the visit by its type, not only by the + * admin-editable `inspectionRequired` flag — turning the flag off must not + * let a vessel be approved, or its certificate issued, on paperwork alone. + */ +describe("resolveActions — vessel registration needs a passed inspection", () => { + function vessel( + status: string, + events: string[], + latestInspectionPassed: boolean | null, + licenseTypeKey = "VESSEL_REGISTRATION", + ) { + const detail = { + application: { + id: "app-1", + status, + assignedOfficerId: "me", + licenseType: { key: licenseTypeKey, inspectionRequired: false }, + }, + availableEvents: events, + } as unknown as ApplicationDetail; + return resolveActions({ + detail, + currentUserId: "me", + can: () => true, + reasons: REASONS, + flaggedCount: 1, + hasPendingInspection: false, + inspectionNotYetDue: false, + latestInspectionPassed, + allDocumentsAccepted: true, + }); + } + + it("disables final approve with no passed inspection, flag or not", () => { + const approve = vessel("UNDER_EVALUATION", ["final-approve"], null).find( + (a) => a.id === "final-approve", + ); + expect(approve?.enabled).toBe(false); + expect(approve?.disabledReason).toBe(REASONS.needsInspection); + }); + + it("disables final approve after a failed inspection", () => { + const approve = vessel("UNDER_EVALUATION", ["final-approve"], false).find( + (a) => a.id === "final-approve", + ); + expect(approve?.enabled).toBe(false); + expect(approve?.disabledReason).toBe(REASONS.inspectionFailed); + }); + + it("enables final approve once the inspection passed", () => { + const approve = vessel("UNDER_EVALUATION", ["final-approve"], true).find( + (a) => a.id === "final-approve", + ); + expect(approve?.enabled).toBe(true); + }); + + it("withholds the certificate until the inspection passed", () => { + const issue = vessel("SCHEDULED", ["issue-certificate"], null).find( + (a) => a.id === "issue-certificate", + ); + expect(issue?.enabled).toBe(false); + expect(issue?.disabledReason).toBe(REASONS.needsInspection); + expect( + vessel("SCHEDULED", ["issue-certificate"], true).find( + (a) => a.id === "issue-certificate", + )?.enabled, + ).toBe(true); + }); + + it("leaves a non-inspected type alone", () => { + const approve = vessel( + "UNDER_EVALUATION", + ["final-approve"], + null, + "SEAFARER_REGISTRATION", + ).find((a) => a.id === "final-approve"); + expect(approve?.enabled).toBe(true); + }); +}); 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 1a407ecf2..d00b6357e 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -1,5 +1,8 @@ import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; -import { isSeafarerCertificate } from './license-types'; +import { + isSeafarerCertificate, + requiresPassedInspection, +} from './license-types'; /** * Where an action is rendered. One tier per action, decided here rather than @@ -429,7 +432,11 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { // A seafarer's certificate is reviewed by whoever opens it. There is no // queue to claim it from and no reviewer to assign, so the two actions that // move it through one are dropped and ownership stops gating the decisions. - const skipsAssignment = isSeafarerCertificate(app.licenseType?.key); + const skipsAssignment = isSeafarerCertificate(app.licenseType); + // A vessel registration is decided on the visit, not the file: nothing may + // be approved or issued until the latest conducted inspection PASSED, and + // that holds even if the type's `inspectionRequired` flag has been edited. + const mustPassInspection = requiresPassedInspection(app.licenseType?.key); return ACTIONS.filter((action) => can(action.permissions)).flatMap( (action) => { @@ -520,20 +527,41 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { if (needsCapital && app.capitalAmountVerified == null) { return disabled(reasons.needsCapital); } - if (app.licenseType?.inspectionRequired) { - if (app.status !== 'INSPECTION_COMPLETED') { - return disabled(reasons.needsInspection); - } - // Mirrors the server's `inspection_not_passed`: the status says a - // visit happened, the result says whether it may be approved. - if (ctx.latestInspectionPassed !== true) { - return disabled( - ctx.latestInspectionPassed === false - ? reasons.inspectionFailed - : reasons.needsInspection, - ); - } + if ( + app.licenseType?.inspectionRequired && + app.status !== 'INSPECTION_COMPLETED' + ) { + return disabled(reasons.needsInspection); } + // Mirrors the server's `inspection_not_passed`: the status says a + // visit happened, the result says whether it may be approved. + if ( + (app.licenseType?.inspectionRequired || mustPassInspection) && + ctx.latestInspectionPassed !== true + ) { + return disabled( + ctx.latestInspectionPassed === false + ? reasons.inspectionFailed + : reasons.needsInspection, + ); + } + } + + // The certificate is the other thing a passed visit unlocks. Approval + // is already gated above, so this only bites on a file that reached + // issuance some other way — but a vessel certificate with no passed + // inspection behind it is exactly the document this rule exists to + // prevent, so it is checked at the point of issue too. + if ( + (action.id === 'schedule-issuance' || action.id === 'issue-certificate') && + mustPassInspection && + ctx.latestInspectionPassed !== true + ) { + return disabled( + ctx.latestInspectionPassed === false + ? reasons.inspectionFailed + : reasons.needsInspection, + ); } return { ...action, enabled: true }; 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 7bd1ac9b7..1a5b3bbf6 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 @@ -10,7 +10,11 @@ import { IconUsers, type Icon, } from '@tabler/icons-react'; -import type { LicenseApplication, LicenseType } from '@ema-platform/api'; +import type { + CertificateCategory, + LicenseApplication, + LicenseType, +} from '@ema-platform/api'; /** * Presentation-only metadata per licence type. @@ -152,12 +156,33 @@ const PRESENTATION: Record = { const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_']; const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents']; +/** The configured categories that mark a type as a seafarer's own certificate. */ +const SEAFARER_CERTIFICATE_CATEGORIES: ReadonlyArray = [ + 'COC', + 'COP', + 'GOC', +]; + /** - * CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather - * than an organisation's licence. They skip the claim/assign queue — see + * CoC, CoP and the GOC family: a seafarer's own certificate rather than an + * organisation's licence. They skip the claim/assign queue — see * `resolveActions`. + * + * Decided by the `certificateCategory` an administrator sets on the + * certificate-requirements Behaviour tab, so a type configured as a CoC in the + * backoffice is treated as one whatever its key. The key prefixes are only a + * fallback for a payload that carries no category field at all (an older + * server, or a bare key). */ -export function isSeafarerCertificate(key: string | undefined): boolean { +export function isSeafarerCertificate( + licenseType: Pick | string | undefined, +): boolean { + if (!licenseType) return false; + if (typeof licenseType !== 'string' && licenseType.certificateCategory !== undefined) { + const category = licenseType.certificateCategory; + return category !== null && SEAFARER_CERTIFICATE_CATEGORIES.includes(category); + } + const key = typeof licenseType === 'string' ? licenseType : licenseType.key; if (!key) return false; return ( CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) || @@ -166,6 +191,21 @@ export function isSeafarerCertificate(key: string | undefined): boolean { ); } +/** + * Types that may never be approved, nor have a certificate issued, without a + * conducted inspection that PASSED (US-VES-007 for vessel registration). + * + * Keyed on the type rather than `inspectionRequired` alone: that flag is an + * admin-editable column, and flipping it off must not quietly let a vessel be + * registered on paperwork alone. `resolveActions` applies this on top of the + * flag, never instead of it. + */ +const PASSED_INSPECTION_REQUIRED_KEYS = new Set(['VESSEL_REGISTRATION']); + +export function requiresPassedInspection(key: string | undefined): boolean { + return Boolean(key && PASSED_INSPECTION_REQUIRED_KEYS.has(key)); +} + /** Falls back to a generic presentation so an unseeded type still renders. */ export function presentationFor(key: string | undefined): LicenseTypePresentation { if (key && PRESENTATION[key]) return PRESENTATION[key]; 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 937dbdec9..bbdace8e0 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 @@ -55,6 +55,7 @@ import { AmharicDatePicker, type AdvancedColumn, } from "@ema-platform/ui"; +import { LICENSE_PERMISSIONS, RequirePermission } from "@ema-platform/auth"; import { DEFAULT_VIEW, SAVED_VIEWS, @@ -133,6 +134,7 @@ function statusesFor(type: LicenseType | undefined): LicenseStatus[] { return type.inspectionRequired; } if (EXAM_ONLY_STATUSES.includes(status)) return Boolean(type.requiresExamination); + if (status === "SCHEDULED") return Boolean(type.requiresIssuanceScheduling); if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate; return true; }); 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 8f4b51c39..bcb410228 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 @@ -104,6 +104,7 @@ import { reviewStaffColumns } from "./columns"; import { evaluateEligibility, presentationFor, + requiresPassedInspection, } from "../../config/license-types"; import { resolveActions, @@ -343,6 +344,30 @@ export function LicenseReviewPage() { ? latestConductedInspection.result === 'PASSED' : null; + // A vessel registration's visit is pass/fail per area, nothing in between: + // there is no "fix and come back" state on a vessel, so the result form + // offers only those two, and the visit can only be recorded as PASSED when + // every area passed (US-VES-007). + const strictInspection = requiresPassedInspection( + loadedApp?.licenseType?.key, + ); + const checklistOptions = [ + { value: "PASS", label: t("review.checkPass", "Pass") }, + ...(strictInspection + ? [] + : [ + { + value: "NEEDS_CORRECTION", + label: t("review.checkFix", "Fix"), + }, + ]), + { value: "FAIL", label: t("review.checkFail", "Fail") }, + ]; + const allChecklistPassed = INSPECTION_CHECKLIST_ITEMS.every( + (item) => (checklist[item.key] ?? "PASS") === "PASS", + ); + const passBlocked = strictInspection && !allChecklistPassed; + const { data: findingsEvidence = [], refetch: refetchFindingsEvidence } = useGetAttachmentsQuery( { ownerType: 'INSPECTION', ownerId: pendingInspection?.id ?? '' }, @@ -1025,9 +1050,8 @@ export function LicenseReviewPage() { style={{ cursor: "pointer" }} onClick={() => navigate(`/licence-review/${related.id}`)} > - {related.licenseType?.key === "SEAMAN_BOOK" - ? "Seaman Book" - : "BTC"}{" "} + {localized(related.licenseType?.name) || + related.licenseType?.key}{" "} · {related.applicationNumber} ·{" "} {STATUS_LABELS[related.status]} @@ -1695,17 +1719,18 @@ export function LicenseReviewPage() { [item.key]: value as "PASS" | "FAIL" | "NEEDS_CORRECTION", })) } - data={[ - { value: "PASS", label: t("review.checkPass", "Pass") }, - { - value: "NEEDS_CORRECTION", - label: t("review.checkFix", "Fix"), - }, - { value: "FAIL", label: t("review.checkFail", "Fail") }, - ]} + data={checklistOptions} /> ))} + {passBlocked && ( + + {t( + "review.checklistMustPass", + "Every item must pass before the inspection can be recorded as passed.", + )} + + )}