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

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

View File

@@ -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(() => {

View File

@@ -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") && (

View File

@@ -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'

View File

@@ -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('');

View File

@@ -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>

View File

@@ -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: 'ግምገማ',

View File

@@ -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',

View File

@@ -269,7 +269,7 @@ export const router = createBrowserRouter([
path: "/basic-training-certificate",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage />
<SeamanBookPage service="BTC" />
</RequirePermission>
),
},