From 548846a1bd937647908102d8f3bb9901518f4088 Mon Sep 17 00:00:00 2001 From: nati14575 Date: Wed, 19 Aug 2026 12:58:24 +0300 Subject: [PATCH] feat: update routing and navigation for Basic Training Certificate and Seaman Book applications --- .../pages/LicenseReviewPage/index.tsx | 45 +- .../seaman-book/pages/SeamanBookPage.tsx | 390 +++++++++++++----- apps/portal/src/app/layouts/PortalLayout.tsx | 3 +- apps/portal/src/app/router.tsx | 54 ++- .../lib/features/licensing/licensing.types.ts | 32 +- 5 files changed, 396 insertions(+), 128 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 5930e7f80..1ae91afa1 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, @@ -146,6 +146,7 @@ function buildChecklist( * position instead of scrolling back to a column of buttons. */ export function LicenseReviewPage() { + const navigate = useNavigate(); const { t, i18n } = useTranslation(); const showDate = useDateDisplayer(); const localized = useLocalized(); @@ -672,6 +673,12 @@ export function LicenseReviewPage() { applicantFullName || applicantOrCompanyName(app) || app.applicationNumber; + const linkedBookServices = (data.relatedApplications ?? []).filter( + (related) => + ["SEAMAN_BOOK", "BTC_BASIC_TRAINING"].includes( + related.licenseType?.key ?? "", + ), + ); return ( @@ -694,6 +701,25 @@ export function LicenseReviewPage() { )} + {linkedBookServices.length > 1 && ( + + {linkedBookServices.map((related) => ( + navigate(`/licence-review/${related.id}`)} + > + {related.licenseType?.key === "SEAMAN_BOOK" + ? "Seaman Book" + : "BTC"}{" "} + · {related.applicationNumber} ·{" "} + {STATUS_LABELS[related.status]} + + ))} + + )} - run(async () => { - await scheduleIssuance({ - id, - scheduledDate: issuanceDate, - }).unwrap(); - setIssuanceOpen(false); - }, t("review.done.scheduleIssuance", "Pickup scheduled")) + run( + async () => { + await scheduleIssuance({ + id, + scheduledDate: issuanceDate, + }).unwrap(); + setIssuanceOpen(false); + }, + t("review.done.scheduleIssuance", "Pickup scheduled"), + ) } > diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx index d793e6592..91e65a622 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx @@ -1,5 +1,10 @@ -import { useNavigate } from 'react-router-dom'; -import { useApiQuery } from '@ema-platform/api'; +import { useNavigate } from "react-router-dom"; +import { + useApiQuery, + useBypassPaymentMutation, + useGetPaymentCapabilitiesQuery, +} from "@ema-platform/api"; +import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment"; import { Alert, Badge, @@ -15,7 +20,7 @@ import { Text, ThemeIcon, Title, -} from '@mantine/core'; +} from "@mantine/core"; import { IconAlertCircle, IconBook2, @@ -28,7 +33,7 @@ import { IconPrinter, IconShield, IconX, -} from '@tabler/icons-react'; +} from "@tabler/icons-react"; interface ApplicationSummary { id: string; @@ -73,19 +78,24 @@ interface SeamanBookOverview { * same journey would only drift out of step with it. */ const STAGES: { label: string; statuses: string[] }[] = [ - { label: 'Submitted', statuses: ['SUBMITTED', 'UNDER_REVIEW', 'UNDER_EVALUATION'] }, - { label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] }, - { label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] }, - { label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] }, + { + label: "Submitted", + statuses: ["SUBMITTED", "UNDER_REVIEW", "UNDER_EVALUATION"], + }, + { label: "Under Review", statuses: ["UNDER_REVIEW", "UNDER_EVALUATION"] }, + { + label: "Approved", + statuses: ["APPROVED", "PAYMENT_PENDING", "PAID", "PAYMENT_CONFIRMED"], + }, // Printed once, handed over in person — an officer sets a pickup date // before this reaches CERTIFICATE_ISSUED. - { label: 'Pickup Scheduled', statuses: ['SCHEDULED'] }, - { label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] }, + { label: "Pickup Scheduled", statuses: ["SCHEDULED"] }, + { label: "Issued", statuses: ["CERTIFICATE_ISSUED", "COMPLETED"] }, ]; /** How far along the stepper a status sits; -1 for a draft. */ function stageIndexFor(status: string | undefined): number { - if (!status || status === 'DRAFT') return -1; + if (!status || status === "DRAFT") return -1; let reached = -1; STAGES.forEach((stage, i) => { if (stage.statuses.includes(status)) reached = i; @@ -99,39 +109,46 @@ function stageIndexFor(status: string | undefined): number { // reads whatever the API reports, and an unmapped status falls back to grey // rather than vanishing. const STATUS_COLOR: Record = { - DRAFT: 'gray', - SUBMITTED: 'blue', - UNDER_REVIEW: 'yellow', - UNDER_EVALUATION: 'yellow', - RESUBMIT_REQUIRED: 'orange', - INSPECTION_PENDING: 'grape', - INSPECTION_COMPLETED: 'grape', - APPROVED: 'teal', - REJECTED: 'red', - ON_HOLD: 'orange', - PAYMENT_PENDING: 'orange', - PAID: 'blue', - PAYMENT_CONFIRMED: 'blue', - SCHEDULED: 'grape', - CERTIFICATE_ISSUED: 'teal', - COMPLETED: 'teal', + DRAFT: "gray", + SUBMITTED: "blue", + UNDER_REVIEW: "yellow", + UNDER_EVALUATION: "yellow", + RESUBMIT_REQUIRED: "orange", + INSPECTION_PENDING: "grape", + INSPECTION_COMPLETED: "grape", + APPROVED: "teal", + REJECTED: "red", + ON_HOLD: "orange", + PAYMENT_PENDING: "orange", + PAID: "blue", + PAYMENT_CONFIRMED: "blue", + SCHEDULED: "grape", + CERTIFICATE_ISSUED: "teal", + COMPLETED: "teal", }; function formatDate(value: string): string { - return new Date(value).toLocaleDateString('en-GB', { - day: '2-digit', - month: 'short', - year: 'numeric', + return new Date(value).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", }); } function EligibilityItem({ label, ok }: { label: string; ok: boolean }) { return ( - + {ok ? : } - {label} + + {label} + ); } @@ -170,17 +187,17 @@ function ApplicationCard({ {/* An approved seafarer registration opens this application as a draft, so it can be here before anyone has filed it. Calling that "Submitted" would misreport where it stands. */} - {application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '} + {application.status === "DRAFT" ? "Opened" : "Submitted"}{" "} {formatDate(application.submittedAt)} - {application.status.replaceAll('_', ' ')} + {application.status.replaceAll("_", " ")} @@ -189,7 +206,7 @@ function ApplicationCard({ @@ -209,13 +226,28 @@ function ApplicationCard({ // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- -export function SeamanBookPage() { +export function SeamanBookPage({ + service = "COMBINED", +}: { + service?: "COMBINED" | "SEAMAN_BOOK" | "BTC"; +}) { const navigate = useNavigate(); + const { pay, isPaying } = useApplicationPayment(); + const isBtc = service === "BTC"; + const isCombined = service === "COMBINED"; - const { data, isLoading } = useApiQuery({ - url: '/seaman-book/my', - method: 'GET', + const { data, isLoading, refetch } = useApiQuery({ + url: "/seaman-book/my", + method: "GET", }); + const { data: paymentCapabilities } = useGetPaymentCapabilitiesQuery(); + const [bypassPayment, { isLoading: bypassingPayment }] = + useBypassPaymentMutation(); + + const completeTestPayment = async (applicationId: string) => { + await bypassPayment(applicationId).unwrap(); + refetch(); + }; const application = data?.application ?? null; const btcApplication = data?.btcApplication ?? null; @@ -229,48 +261,128 @@ export function SeamanBookPage() { // Either service already being in flight means there is nothing to apply for // here — an approved registration opens both, so offering "Apply" alongside // them would invite a duplicate the server refuses anyway. - const submitted = Boolean(application || btcApplication); + const submitted = Boolean( + isCombined + ? application || btcApplication + : isBtc + ? btcApplication + : application, + ); return ( {/* Header */}
- My Application — Seaman Book & BTC + + My Application —{" "} + {isCombined + ? "Seaman Book & Basic Training Certificate" + : isBtc + ? "Basic Training Certificate" + : "Seaman Book"} + - A Seaman Book is your official maritime identity document. It records your sea service and must be - held before joining any vessel. + {isCombined + ? "Track both applications together and pay each service separately." + : isBtc + ? "Track and manage your Basic Training Certificate application." + : "A Seaman Book is your official maritime identity document. It records your sea service and must be held before joining any vessel."}
{/* Active application status — one card per service in flight. */} - {application && ( + {(isCombined || !isBtc) && application && ( + {application.status === "PAYMENT_PENDING" && ( + + + {paymentCapabilities?.bypassEnabled && ( + + )} + + )} {data?.book ? ( - } mt="md"> + } + mt="md" + > Your Seaman Book {data.book.id} has been issued. - Please visit the EMA office to collect it, bringing your National ID. + Please visit the EMA office to collect it, bringing your National + ID. ) : ( - application.status === 'SCHEDULED' && + application.status === "SCHEDULED" && application.scheduledIssuanceDate && ( - } mt="md"> - Your Seaman Book is ready for collection on{' '} - {formatDate(application.scheduledIssuanceDate)}. - Please visit the EMA office on that date, bringing your National ID. + } + mt="md" + > + Your Seaman Book is ready for collection on{" "} + {formatDate(application.scheduledIssuanceDate)} + . Please visit the EMA office on that date, bringing your + National ID. ) )} )} - {btcApplication && ( - - {btcApplication.status === 'SCHEDULED' && btcApplication.scheduledIssuanceDate && ( - } mt="md"> - Your Basic Training Certificate is ready for collection on{' '} - {formatDate(btcApplication.scheduledIssuanceDate)}. - Please visit the EMA office on that date, bringing your National ID. - + {(isCombined || isBtc) && btcApplication && ( + + {btcApplication.status === "PAYMENT_PENDING" && ( + + + {paymentCapabilities?.bypassEnabled && ( + + )} + )} + {btcApplication.status === "SCHEDULED" && + btcApplication.scheduledIssuanceDate && ( + } + mt="md" + > + Your Basic Training Certificate is ready for collection on{" "} + + {formatDate(btcApplication.scheduledIssuanceDate)} + + . Please visit the EMA office on that date, bringing your + National ID. + + )} )} @@ -280,7 +392,12 @@ export function SeamanBookPage() { {/* Eligibility checklist */} - + Eligibility Requirements @@ -299,7 +416,7 @@ export function SeamanBookPage() { label={ eligibility?.medicalExpiry ? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})` - : 'Valid medical certificate uploaded' + : "Valid medical certificate uploaded" } ok={Boolean(eligibility?.hasMedical)} /> @@ -310,23 +427,42 @@ export function SeamanBookPage() { my={4} /> {bstItems.map((item) => ( - + ))} {!isLoading && !isEligible && ( - } mt="xs" p="sm"> + } + mt="xs" + p="sm" + > Complete all requirements above before applying. {bstItems.length > bstDone ? ` Missing BST: ${bstItems.length - bstDone} certificate(s).` - : ''} + : ""} )} {isEligible && ( - } mt="xs" p="sm"> - You meet all requirements. You may proceed with your application. + } + mt="xs" + p="sm" + > + + You meet all requirements. You may proceed with your + application. + )}
@@ -343,24 +479,30 @@ export function SeamanBookPage() { - Upon submitting your application, EMA Registration Officers will verify your profile, - documents, medical certificate, and Basic Safety Training certificates. You will be - notified at each stage by email and SMS. + Upon submitting your application, EMA Registration Officers will + verify your profile, documents, medical certificate, and Basic + Safety Training certificates. You will be notified at each stage + by email and SMS. - What will be verified: + + What will be verified: + {[ - 'Full seafarer profile', - 'National ID / Fayda authenticity', - 'Medical certificate validity', - 'All 5 Basic Safety Training certificates', - 'Passport size photo', + "Full seafarer profile", + "National ID / Fayda authenticity", + "Medical certificate validity", + "All 5 Basic Safety Training certificates", + "Passport size photo", ].map((item) => ( - + {item} ))} @@ -373,8 +515,12 @@ export function SeamanBookPage() {
- Processing time - 5–7 working days + + Processing time + + + 5–7 working days +
@@ -382,22 +528,38 @@ export function SeamanBookPage() {
- Medical validity - 2 years (STCW) + + Medical validity + + + 2 years (STCW) +
- } p="xs"> + } + p="xs" + > - Application fee will be communicated during the review process. Payment can be made online or at the EMA office. + Application fee will be communicated during the review + process. Payment can be made online or at the EMA office.