mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 20:05:42 +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
|
||||
|
||||
Reference in New Issue
Block a user