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.",
+ )}
+
+ )}