diff --git a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx index 07d777512..d4bede751 100644 --- a/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx +++ b/apps/portal/src/app/features/endorsement/pages/EndorsementPage.tsx @@ -1,20 +1,251 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; +import { + Alert, + Badge, + Button, + Card, + Group, + List, + Loader, + Stack, + Table, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconArrowRight, + IconCertificate, + IconCircleCheck, + IconCircleX, + IconInfoCircle, +} from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import { + STATUS_COLORS, + STATUS_LABELS, + TERMINAL_STATUSES, + extractErrorMessage, + localized, + useGetCertificateUrlMutation, + useGetMyApplicationsQuery, + useGetMyLicensesQuery, +} from '@ema-platform/api'; +import { useCurrentProfile } from '@ema-platform/auth'; +import { notify } from '@ema-platform/ui'; + +const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC']; + +function EligibilityItem({ ok, label }: { ok: boolean; label: string }) { + return ( + + {ok ? : } + + } + > + {label} + + ); +} /** - * Placeholder until this feature has a backend. - * - * This page previously rendered hardcoded sample records, which were - * indistinguishable from real ones. + * Endorsements home, mirroring CertificatesPage: eligibility at a glance, the + * two application entry points (CoC / GOC), and the seafarer's endorsement + * applications and issued endorsements. The wizard itself is the + * config-driven licensing flow. */ export function EndorsementPage() { + const navigate = useNavigate(); + const { profile, isLoading: loadingProfile } = useCurrentProfile(); + const { data: applications, isLoading: loadingApplications } = + useGetMyApplicationsQuery(); + const { data: licenses } = useGetMyLicensesQuery(); + const [getCertificateUrl] = useGetCertificateUrlMutation(); + + const registered = + Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE'; + + const endorsementApplications = (applications?.items ?? []).filter((app) => + ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''), + ); + const inFlight = endorsementApplications.filter( + (app) => !TERMINAL_STATUSES.includes(app.status), + ); + const issued = (licenses?.items ?? []).filter((license) => + ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''), + ); + + async function download(licenseId: string) { + try { + const result = await getCertificateUrl(licenseId).unwrap(); + window.open(result.url, '_blank', 'noopener'); + } catch (error) { + notify.error(extractErrorMessage(error, 'Could not fetch endorsement')); + } + } + + if (loadingProfile || loadingApplications) { + return ( + + + + ); + } + return ( - - - + + My Endorsements + + + + + + Eligibility + + + + + + + } + onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')} + > + Endorse a CoC + + } + onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')} + > + Endorse a GOC + + + + {!registered && ( + }> + Complete your{' '} + navigate('/seafarer-registration')} + > + seafarer registration + {' '} + first — endorsement applications are refused without it. + + )} + + + {inFlight.length > 0 && ( + + Applications in progress + {inFlight.map((app) => ( + + + + {app.applicationNumber} + + {localized(app.licenseType?.name)} + + + + + {STATUS_LABELS[app.status]} + + + navigate( + `/licensing/${app.licenseType?.key}/applications/${app.id}`, + ) + } + > + {app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED' + ? 'Continue' + : 'View'} + + + + + ))} + + )} + + + Issued endorsements + {issued.length === 0 ? ( + + + No endorsements issued yet. + + + ) : ( + + + + + Certificate № + Type + Issued + Expires + Status + + + + + {issued.map((license) => ( + + + + {license.certificateNumber} + + + {localized(license.licenseType?.name)} + {license.issueDate?.slice(0, 10)} + {license.expiryDate?.slice(0, 10)} + + + {license.status} + + + + } + onClick={() => download(license.id)} + > + Download + + + + ))} + + + + )} + + ); } diff --git a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx index b29044c4e..3e0cde509 100644 --- a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx +++ b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx @@ -52,6 +52,8 @@ const MODE_FREE_TYPE_KEYS = [ 'VESSEL_OWNERSHIP_TRANSFER', 'CERTIFICATE_OF_COMPETENCY', 'CERTIFICATE_OF_PROFICIENCY', + 'ENDORSEMENT_COC', + 'ENDORSEMENT_GOC', 'PRE_WAIVER', 'POST_WAIVER', ]; diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 1f2610064..dcf5e0ce7 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -68,7 +68,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [ { to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true }, { to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck }, { to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList }, - { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, soon: true }, + { to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp }, ], }, {