From c538d0b8a2ef38a7da947c11e1373a77e87d5d24 Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 11 Aug 2026 07:57:12 +0000 Subject: [PATCH] feat: add VesselTransferPage for managing vessel ownership transfers --- .../pages/VesselTransferPage.tsx | 230 ++++++++++++++++++ apps/portal/src/app/router.tsx | 6 +- 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx new file mode 100644 index 000000000..5798fc576 --- /dev/null +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselTransferPage.tsx @@ -0,0 +1,230 @@ +import { useNavigate } from 'react-router-dom'; +import { + Badge, + Button, + Card, + Group, + Loader, + Paper, + Progress, + Stack, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconArrowRight, + IconArrowsExchange, + IconShip, +} from '@tabler/icons-react'; +import { + STATUS_COLORS, + STATUS_LABELS, + STATUS_PROGRESS, + TERMINAL_STATUSES, + useGetMyApplicationsQuery, + useGetMyVesselsQuery, +} from '@ema-platform/api'; + +const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER'; + +const CATEGORY_LABELS: Record = { + INLAND_WATERWAY: 'Inland Waterway', + SEA_GOING: 'Sea-going', +}; + +const VESSEL_STATUS_COLORS: Record = { + REGISTERED: 'green', + SUSPENDED: 'orange', + DEREGISTERED: 'gray', +}; + +/** + * The vessel owner's ownership-transfer home: transfers in flight, and the + * registered vessels eligible to start one. Same config-driven wizard as + * registration underneath (VESSEL_OWNERSHIP_TRANSFER) — this page mirrors + * VesselRegistrationPage, just a different entry point and no + * certificate/renewal/incident actions, which don't apply here. + */ +export function VesselTransferPage() { + const navigate = useNavigate(); + const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery(); + const { data: applications, isLoading: loadingApplications } = + useGetMyApplicationsQuery(); + + const inFlight = (applications?.items ?? []).filter( + (app) => + app.licenseType?.key === TRANSFER_TYPE_KEY && + !TERMINAL_STATUSES.includes(app.status), + ); + + // A transfer moves ownership of a vessel already on the register — nothing + // to transfer without at least one REGISTERED vessel. + const hasTransferableVessel = (vessels ?? []).some( + (v) => v.status === 'REGISTERED', + ); + + if (loadingVessels || loadingApplications) { + return ( + + + + ); + } + + return ( + + + Ownership Transfer + + + + + + {/* ----------------------------------------------------- in-flight */} + {inFlight.length > 0 && ( + + Transfers in progress + {inFlight.map((app) => { + const isDraft = app.status === 'DRAFT'; + const needsAction = app.status === 'RESUBMIT_REQUIRED'; + return ( + + +
+ {app.applicationNumber} + +
+ + + {STATUS_LABELS[app.status]} + + + +
+
+ ); + })} +
+ )} + + {/* ------------------------------------------------------- vessels */} + + My vessels + {(vessels ?? []).length === 0 ? ( + + + + No registered vessels yet + + Ownership can only be transferred for a vessel already on the + register. + + + + + ) : ( + + + + + Registration № + Vessel + Category + Status + + + + + {(vessels ?? []).map((vessel) => ( + + + + {vessel.registrationNumber} + + + + + {vessel.name} + + + {vessel.vesselType ?? '—'} + + + + {CATEGORY_LABELS[vessel.category] ?? vessel.category} + + + + {vessel.status} + + + + + {vessel.status === 'REGISTERED' ? ( + + + + ) : ( + + Not transferable + + )} + + + + ))} + +
+
+ )} +
+
+ ); +} + +export default VesselTransferPage; diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 081714fae..34094b2b9 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -38,6 +38,7 @@ import { CoCApplicationPage } from "./features/certificates/pages/CoCApplication // Phase 3 — Endorsement import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage"; import { VesselRegistrationPage } from "./features/vessel-registration/pages/VesselRegistrationPage"; +import { VesselTransferPage } from "./features/vessel-registration/pages/VesselTransferPage"; import { MyApplicationsPage } from "./features/licensing/pages/MyApplicationsPage"; import { PaymentCheckPage } from "./features/payments/pages/PaymentCheckPage"; import { PaymentSuccessPage } from "./features/payments/pages/PaymentSuccessPage"; @@ -176,10 +177,7 @@ export const router = createBrowserRouter([ path: "/vessel-registration-dashboard", element: , }, - { - path: "/vessel-registration/transfer", - element: , - }, + { path: "/vessel-registration/transfer", element: }, // Legacy per-licence-type URLs. Each once had its own hand-written page // that posted to a `/logistics-licenses/*` endpoint the API never had, // and dropped every uploaded document on the floor. They are kept as