mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 23:35:43 +00:00
refactor: enhance license configuration with per-type renewal windows, inspection requirements, and issuance policies
This commit is contained in:
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user