mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 13:05:46 +00:00
refactor: enhance license configuration with per-type renewal windows, inspection requirements, and issuance policies
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@ export function DashboardPage() {
|
||||
take: 1,
|
||||
});
|
||||
const btcDocuments = useListSeafarerDocumentsQuery({
|
||||
kind: 'BASIC_TRAINING',
|
||||
kind: 'BTC_BASIC_TRAINING',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "ካፒታል ከሚያስፈልገው በታች",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
useApiQuery,
|
||||
useBypassPaymentMutation,
|
||||
useDiscardApplicationMutation,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
type LicenseType,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
@@ -163,6 +165,23 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A licence type's validity the way a certificate would print it: days win
|
||||
* over months, whole years read as years.
|
||||
*/
|
||||
function validityLabel(type: LicenseType): string {
|
||||
if (type.validityDays) {
|
||||
return `${type.validityDays} ${type.validityDays === 1 ? 'day' : 'days'}`;
|
||||
}
|
||||
const months = type.validityMonths || 12;
|
||||
if (months % 12 === 0) {
|
||||
const years = months / 12;
|
||||
return `${years} ${years === 1 ? 'year' : 'years'}`;
|
||||
}
|
||||
return `${months} ${months === 1 ? 'month' : 'months'}`;
|
||||
}
|
||||
|
||||
export function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const profileId = authStorage.getProfileId() ?? '';
|
||||
@@ -251,6 +270,33 @@ export function CertificatesPage() {
|
||||
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
|
||||
|
||||
// The certificate types on offer and how long each is valid come from the
|
||||
// licence-type rows the backoffice configures (certificate category and
|
||||
// validity on the Behaviour tab), not from a list kept here.
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const certificateTypes = (licenseTypes?.items ?? []).filter(
|
||||
(type) =>
|
||||
type.isActive !== false &&
|
||||
(type.certificateCategory === 'COC' || type.certificateCategory === 'COP'),
|
||||
);
|
||||
const applyTargets =
|
||||
certificateTypes.length > 0
|
||||
? certificateTypes.map((type) => ({
|
||||
key: type.key,
|
||||
label: type.certificateCategory === 'COC' ? 'CoC' : 'CoP',
|
||||
variant: (type.certificateCategory === 'COC' ? 'filled' : 'light') as 'filled' | 'light',
|
||||
}))
|
||||
: // Before the catalogue loads, or on a server that has not seeded it.
|
||||
[
|
||||
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
|
||||
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
|
||||
];
|
||||
const validityTerms = [...new Set(certificateTypes.map(validityLabel))];
|
||||
const validityDescription =
|
||||
validityTerms.length > 0
|
||||
? `CoC/CoP certificates are valid for ${validityTerms.join(' or ')} and must be revalidated before expiry.`
|
||||
: 'CoC/CoP certificates are valid for a fixed term and must be revalidated before expiry.';
|
||||
|
||||
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
|
||||
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
|
||||
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
|
||||
@@ -315,10 +361,7 @@ export function CertificatesPage() {
|
||||
disabled, so the reason still shows on hover. */}
|
||||
<span>
|
||||
<Group gap="xs">
|
||||
{([
|
||||
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
|
||||
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
|
||||
]).map(({ key, label, variant }) => {
|
||||
{applyTargets.map(({ key, label, variant }) => {
|
||||
const hasDraft = draftTypeKeys.has(key);
|
||||
return (
|
||||
<Button
|
||||
@@ -356,7 +399,7 @@ export function CertificatesPage() {
|
||||
{[
|
||||
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
||||
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Validity', desc: validityDescription },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
|
||||
@@ -61,7 +61,13 @@ import { QuickActionsCard } from './QuickActionsCard';
|
||||
import { OnboardingChecklistCard } from './OnboardingChecklistCard';
|
||||
import { ExportStatementModal } from './ExportStatementModal';
|
||||
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
/**
|
||||
* Days before expiry at which a licence is worth flagging when its type
|
||||
* carries no renewal window of its own. The window itself is configured per
|
||||
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
|
||||
* what the API's `renewable` flag is computed from, so it is read off the
|
||||
* licence rather than assumed here.
|
||||
*/
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
@@ -109,8 +115,9 @@ export function DashboardPage() {
|
||||
);
|
||||
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
||||
const expiringSoon = activeLicenses.filter((l) => {
|
||||
const days = daysUntil(l.expiryDate);
|
||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
||||
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
||||
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
|
||||
return days >= 0 && days <= window;
|
||||
});
|
||||
|
||||
const documentsCount = useMemo(() => {
|
||||
|
||||
@@ -490,9 +490,16 @@ export function LicenseApplicationPage() {
|
||||
// underneath them.
|
||||
const editableWhileSubmitted =
|
||||
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||
// A draft of a type EMA has since stopped offering is frozen: the server
|
||||
// refuses every edit and the submit (`assertTypeOpen`), so the form is shown
|
||||
// read-only rather than letting the applicant fill it in and fail at the
|
||||
// end. Anything already submitted keeps moving and is not affected.
|
||||
const typeClosed =
|
||||
application.status === "DRAFT" && config.licenseType.isActive === false;
|
||||
const readOnly =
|
||||
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||
!editableWhileSubmitted;
|
||||
typeClosed ||
|
||||
(!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||
!editableWhileSubmitted);
|
||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||
// to the summary first.
|
||||
@@ -828,6 +835,24 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{typeClosed && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t(
|
||||
"licenseApplication.typeClosedTitle",
|
||||
"This service is no longer offered",
|
||||
)}
|
||||
mb="md"
|
||||
>
|
||||
{t(
|
||||
"licenseApplication.typeClosed",
|
||||
"EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.",
|
||||
{ name: localized(config.licenseType.name) },
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config.licenseType.requiresIssuanceScheduling &&
|
||||
(application.status === "PAYMENT_CONFIRMED" ||
|
||||
application.status === "SCHEDULED") && (
|
||||
|
||||
@@ -98,6 +98,9 @@ export function applicationActionsColumn(
|
||||
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
|
||||
<Button
|
||||
size="xs"
|
||||
// The server freezes a draft of a type that has been closed, so
|
||||
// there is nothing to continue into; discarding stays available.
|
||||
disabled={app.status === 'DRAFT' && app.licenseType?.isActive === false}
|
||||
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
|
||||
variant={
|
||||
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
|
||||
@@ -116,7 +119,9 @@ export function applicationActionsColumn(
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT'
|
||||
? t('applications.actions.continue')
|
||||
? app.licenseType?.isActive === false
|
||||
? t('applications.actions.closed')
|
||||
: t('applications.actions.continue')
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? t('applications.actions.fixResubmit')
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
|
||||
@@ -64,7 +64,13 @@ import { applicationColumns } from './columns';
|
||||
import { applicationActionsColumn } from './actions';
|
||||
import classes from '../MyApplicationsPage.module.css';
|
||||
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
/**
|
||||
* Days before expiry at which a licence is worth flagging when its type
|
||||
* carries no renewal window of its own. The window itself is configured per
|
||||
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
|
||||
* what the API's `renewable` flag is computed from, so it is read off the
|
||||
* licence rather than assumed here.
|
||||
*/
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
@@ -240,7 +246,8 @@ export function MyApplicationsPage() {
|
||||
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
|
||||
const expiringSoon = activeLicences.filter((l) => {
|
||||
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
||||
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
|
||||
return days >= 0 && days <= window;
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -49,7 +49,9 @@ import { useApplicationPayment } from "../../payments/hooks/useApplicationPaymen
|
||||
* The stages a document passes through, for the progress stepper. Derived
|
||||
* from the status the API moves, never stored separately.
|
||||
*/
|
||||
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
|
||||
type Stage = { label: string; statuses: SeafarerDocumentStatus[] };
|
||||
|
||||
const STAGES: Stage[] = [
|
||||
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
|
||||
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
|
||||
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
|
||||
@@ -57,9 +59,26 @@ const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
|
||||
{ label: "Issued", statuses: ["ISSUED"] },
|
||||
];
|
||||
|
||||
function stageIndexFor(status: SeafarerDocumentStatus): number {
|
||||
/**
|
||||
* Whether this document is handed over at the counter. Configured per type
|
||||
* on the backoffice Behaviour tab (`requiresIssuanceScheduling`); an older
|
||||
* server that does not report it is treated as the collected-in-person
|
||||
* default the documents always had.
|
||||
*/
|
||||
function collectedInPerson(document: SeafarerDocument): boolean {
|
||||
return document.requiresIssuanceScheduling ?? true;
|
||||
}
|
||||
|
||||
/** The stepper for one document: no pickup step for a type issued on payment. */
|
||||
function stagesFor(document: SeafarerDocument): Stage[] {
|
||||
return collectedInPerson(document)
|
||||
? STAGES
|
||||
: STAGES.filter((stage) => stage.label !== "Pickup Scheduled");
|
||||
}
|
||||
|
||||
function stageIndexFor(stages: Stage[], status: SeafarerDocumentStatus): number {
|
||||
let reached = -1;
|
||||
STAGES.forEach((stage, i) => {
|
||||
stages.forEach((stage, i) => {
|
||||
if (stage.statuses.includes(status)) reached = i;
|
||||
});
|
||||
return reached;
|
||||
@@ -82,7 +101,12 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
|
||||
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
|
||||
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
|
||||
const activeStep = stageIndexFor(document.status);
|
||||
const stages = stagesFor(document);
|
||||
const activeStep = stageIndexFor(stages, document.status);
|
||||
// Offered inside the type's configured renewal window, and only where the
|
||||
// type allows it — decided by the API, not re-derived from a date here.
|
||||
const renewable = document.renewable ?? true;
|
||||
const reissuable = document.reissuable ?? true;
|
||||
|
||||
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
|
||||
try {
|
||||
@@ -133,7 +157,7 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
|
||||
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
{stages.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
@@ -171,7 +195,9 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
)}
|
||||
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
|
||||
Payment received. The Authority will schedule a date for you to collect your {title}.
|
||||
{collectedInPerson(document)
|
||||
? `Payment received. The Authority will schedule a date for you to collect your ${title}.`
|
||||
: `Payment received. Your ${title} is being issued.`}
|
||||
</Alert>
|
||||
)}
|
||||
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
|
||||
@@ -193,24 +219,28 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={renewing}
|
||||
onClick={() => renewOrReplace(() => renew(document.id))}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconReplace size={14} />}
|
||||
loading={replacing}
|
||||
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
||||
>
|
||||
Report Lost/Damaged
|
||||
</Button>
|
||||
{renewable && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={renewing}
|
||||
onClick={() => renewOrReplace(() => renew(document.id))}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
{reissuable && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconReplace size={14} />}
|
||||
loading={replacing}
|
||||
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
|
||||
>
|
||||
Report Lost/Damaged
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Alert>
|
||||
|
||||
@@ -303,6 +303,7 @@ export const am: Translations = {
|
||||
},
|
||||
actions: {
|
||||
continue: 'ቀጥል',
|
||||
closed: 'አገልግሎቱ ቆሟል',
|
||||
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
||||
pay: '{{amount}} {{currency}} ክፈል',
|
||||
certificate: 'የምስክር ወረቀት',
|
||||
@@ -1032,6 +1033,9 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
licenseApplication: {
|
||||
typeClosedTitle: 'ይህ አገልግሎት ከአሁን በኋላ አይሰጥም',
|
||||
typeClosed:
|
||||
'የ{{name}} አገልግሎት መሰጠቱ ቆሟል። ይህ ረቂቅ ከአሁን በኋላ ሊጠናቀቅ ወይም ሊገባ አይችልም፤ ከማመልከቻዎች ዝርዝርዎ ማጥፋት ይችላሉ።',
|
||||
loading: 'ማመልከቻ በመጫን ላይ…',
|
||||
fee: 'ክፍያ፡ {{amount}} {{currency}}',
|
||||
review: 'ግምገማ',
|
||||
|
||||
@@ -303,6 +303,7 @@ export const en = {
|
||||
},
|
||||
actions: {
|
||||
continue: 'Continue',
|
||||
closed: 'No longer offered',
|
||||
fixResubmit: 'Fix & resubmit',
|
||||
pay: 'Pay {{amount}} {{currency}}',
|
||||
certificate: 'Certificate',
|
||||
@@ -1038,6 +1039,9 @@ export const en = {
|
||||
},
|
||||
|
||||
licenseApplication: {
|
||||
typeClosedTitle: 'This service is no longer offered',
|
||||
typeClosed:
|
||||
'EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.',
|
||||
loading: 'Loading Application…',
|
||||
fee: 'Fee: {{amount}} {{currency}}',
|
||||
review: 'Review',
|
||||
|
||||
@@ -269,7 +269,7 @@ export const router = createBrowserRouter([
|
||||
path: "/basic-training-certificate",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
|
||||
<SeamanBookPage />
|
||||
<SeamanBookPage service="BTC" />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -292,7 +292,9 @@ export const licensingApi = baseApi
|
||||
/**
|
||||
* Opens or closes a type to new applications. Applications already in
|
||||
* flight hold the type by id and keep running; only the portal
|
||||
* catalogue changes.
|
||||
* catalogue changes. Closing also notifies every holder, applicant and
|
||||
* drafter of the type, and freezes their drafts — server-side, so the
|
||||
* same happens whichever client flips it.
|
||||
*/
|
||||
updateLicenseStatus: builder.mutation<
|
||||
LicenseType,
|
||||
|
||||
@@ -39,6 +39,17 @@ export interface SeafarerDocument {
|
||||
issuedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
createdAt: string;
|
||||
/**
|
||||
* Policy from the document's licence-type row, as configured on the
|
||||
* backoffice Behaviour tab. Present on `/seafarer-documents/mine` so the
|
||||
* portal shows the pickup step only for a type that has one, and offers
|
||||
* Renew / Replace only inside the configured window — the same flags
|
||||
* `/licenses/mine` carries for licences. Absent on older servers.
|
||||
*/
|
||||
requiresIssuanceScheduling?: boolean;
|
||||
daysUntilExpiry?: number | null;
|
||||
renewable?: boolean;
|
||||
reissuable?: boolean;
|
||||
}
|
||||
|
||||
export interface SeafarerDocumentApplicant {
|
||||
|
||||
Reference in New Issue
Block a user