refactor: enhance license configuration with per-type renewal windows, inspection requirements, and issuance policies

This commit is contained in:
estifanos
2026-09-07 08:59:21 +00:00
parent 10440238d1
commit a67a29ae79
22 changed files with 449 additions and 88 deletions

View File

@@ -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: 13650 days, or 6240 months.
min={draft.validityDays !== null ? 1 : 6}
// save would reject: 13650 days, or 1240 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.

View File

@@ -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() {
<TextInput label={t('configuration.licenseTypes.currency', 'Currency')} maxLength={8} {...form.getInputProps('feeCurrency')} />
<NumberInput
label={t('configuration.licenseTypes.validity', 'Validity (months)')}
min={6}
min={1}
max={240}
allowDecimal={false}
required

View File

@@ -84,7 +84,7 @@ export function DashboardPage() {
take: 1,
});
const btcDocuments = useListSeafarerDocumentsQuery({
kind: 'BASIC_TRAINING',
kind: 'BTC_BASIC_TRAINING',
status: 'PAYMENT_PENDING',
take: 1,
});

View File

@@ -15,21 +15,27 @@ const REASONS = {
};
/** Held by someone else, so ownership gating is what the assertions turn on. */
function detailFor(licenseTypeKey: string): ApplicationDetail {
function detailFor(
licenseTypeKey: string,
certificateCategory?: string | null,
): ApplicationDetail {
return {
application: {
id: "app-1",
status: "SUBMITTED",
assignedOfficerId: "another-officer",
licenseType: { key: licenseTypeKey },
licenseType:
certificateCategory === undefined
? { key: licenseTypeKey }
: { key: licenseTypeKey, certificateCategory },
},
availableEvents: ["claim", "assign", "request-adjustment"],
} as unknown as ApplicationDetail;
}
function resolve(licenseTypeKey: string) {
function resolve(licenseTypeKey: string, certificateCategory?: string | null) {
return resolveActions({
detail: detailFor(licenseTypeKey),
detail: detailFor(licenseTypeKey, certificateCategory),
currentUserId: "me",
can: () => 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);
});
});

View File

@@ -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<ResolvedAction>(
(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 };

View File

@@ -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<string, LicenseTypePresentation> = {
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<CertificateCategory> = [
'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<LicenseType, 'key' | 'certificateCategory'> | 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];

View File

@@ -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;
});

View File

@@ -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]}
</Badge>
@@ -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}
/>
</Group>
))}
{passBlocked && (
<Text size="xs" c="dimmed">
{t(
"review.checklistMustPass",
"Every item must pass before the inspection can be recorded as passed.",
)}
</Text>
)}
</Stack>
<Textarea
label={t("review.findings", "Findings")}
@@ -1785,7 +1810,12 @@ export function LicenseReviewPage() {
variant="light"
color="teal"
size="lg"
disabled={!findings.trim() || !pendingInspection || inspectionNotYetDue}
disabled={
!findings.trim() ||
!pendingInspection ||
inspectionNotYetDue ||
passBlocked
}
aria-label={t("review.passed", "Passed")}
onClick={() =>
run(

View File

@@ -10,6 +10,7 @@ import {
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
useConfirmSeafarerDocumentPaymentMutation,
useGetLicenseTypesQuery,
useGetSeafarerDocumentReviewQuery,
useIssueSeafarerDocumentMutation,
useLazyGetSeafarerDocumentReviewDownloadQuery,
@@ -60,6 +61,9 @@ export function SeafarerDocumentReviewPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
// The document's policy lives on its licence-type row (the kind is the
// type's key), as configured on the Behaviour tab.
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
@@ -90,6 +94,11 @@ export function SeafarerDocumentReviewPage() {
}
const { document, applicant, payment } = data;
// Collected in person unless the type says otherwise: then payment
// confirmation issues it and there is nothing to schedule.
const collectedInPerson =
licenseTypes?.items.find((type) => type.key === document.kind)
?.requiresIssuanceScheduling ?? true;
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
@@ -156,12 +165,13 @@ export function SeafarerDocumentReviewPage() {
</Button>
</RequirePermission>
)}
{document.status === 'PAYMENT_CONFIRMED' && (
{document.status === 'PAYMENT_CONFIRMED' && collectedInPerson && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
</RequirePermission>
)}
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
{(document.status === 'SCHEDULED' ||
(document.status === 'PAYMENT_CONFIRMED' && !collectedInPerson)) && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
Issue

View File

@@ -961,7 +961,7 @@ export const am: Translations = {
prefixRequired: "የምስክር ወረቀት ቅድመ ቅጥያ ያስፈልጋል",
prefixTooLong: "ቅድመ ቅጥያ ከ12 ቁምፊዎች መብለጥ የለበትም",
currencyTooLong: "አጭር የምንዛሪ ኮድ ይጠቀሙ",
validityRange: "የአገልግሎት ጊዜ በ6 እና 240 ወራት መካከል መሆን አለበት",
validityRange: "የአገልግሎት ጊዜ በ1 እና 240 ወራት መካከል መሆን አለበት",
},
},
personalDocumentsTab: "የግል ሰነዶች",
@@ -1220,6 +1220,8 @@ export const am: Translations = {
},
inspectionFailedBlocked:
"ምርመራው ስላልተሳካ ማጽደቅና ሰርተፍኬት መስጠት አይቻልም። ድጋሚ ምርመራ ያስይዙ፣ ማስተካከያ ይጠይቁ ወይም ማመልከቻውን ውድቅ ያድርጉ።",
checklistMustPass:
"ምርመራው እንደተሳካ ከመመዝገቡ በፊት ሁሉም ነጥቦች ማለፍ አለባቸው።",
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",
belowCapital: "ካፒታል ከሚያስፈልገው በታች",

View File

@@ -967,7 +967,7 @@ export const en = {
prefixRequired: 'Certificate prefix is required',
prefixTooLong: 'Prefix must be at most 12 characters',
currencyTooLong: 'Use a short currency code',
validityRange: 'Validity must be between 6 and 240 months',
validityRange: 'Validity must be between 1 and 240 months',
},
},
personalDocumentsTab: 'Personal Documents',
@@ -1231,6 +1231,8 @@ export const en = {
},
inspectionFailedBlocked:
'Approval and certificate issuance are unavailable because the inspection failed. Schedule a re-inspection, request corrections, or reject the application.',
checklistMustPass:
'Every item must pass before the inspection can be recorded as passed.',
reasons: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',