feat: add VesselTransferPage for managing vessel ownership transfers

This commit is contained in:
estifanos
2026-08-11 07:57:12 +00:00
parent 4c30bdf803
commit c538d0b8a2
2 changed files with 232 additions and 4 deletions

View File

@@ -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<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
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 (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Ownership Transfer</Title>
<Tooltip
label="Register a vessel first — there's nothing to transfer yet"
disabled={hasTransferableVessel}
>
<Button
leftSection={<IconArrowsExchange size={16} />}
disabled={!hasTransferableVessel}
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
>
Start transfer
</Button>
</Tooltip>
</Group>
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Transfers in progress</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
return (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Progress
value={STATUS_PROGRESS[app.status]}
mt="xs"
w={260}
/>
</div>
<Group wrap="nowrap">
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant={needsAction ? 'filled' : 'light'}
color={needsAction ? 'orange' : undefined}
rightSection={<IconArrowRight size={14} />}
onClick={() =>
navigate(
`/licensing/${TRANSFER_TYPE_KEY}/applications/${app.id}`,
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
{/* ------------------------------------------------------- vessels */}
<Stack gap="sm">
<Title order={4}>My vessels</Title>
{(vessels ?? []).length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Stack align="center" gap="sm">
<IconShip size={40} color="var(--mantine-color-blue-5)" />
<Text fw={600}>No registered vessels yet</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
Ownership can only be transferred for a vessel already on the
register.
</Text>
<Button
mt="xs"
variant="light"
onClick={() => navigate('/vessel-registration')}
>
Go to Vessel Registration
</Button>
</Stack>
</Paper>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vessels ?? []).map((vessel) => (
<Table.Tr key={vessel.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end">
{vessel.status === 'REGISTERED' ? (
<Tooltip label="Start an ownership transfer for this vessel">
<Button
size="compact-xs"
variant="light"
leftSection={<IconArrowsExchange size={14} />}
onClick={() =>
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
}
>
Transfer
</Button>
</Tooltip>
) : (
<Text size="xs" c="dimmed">
Not transferable
</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
</Stack>
);
}
export default VesselTransferPage;

View File

@@ -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: <Navigate to="/vessel-registration" replace />,
},
{
path: "/vessel-registration/transfer",
element: <Navigate to="/licensing/VESSEL_OWNERSHIP_TRANSFER/apply" replace />,
},
{ path: "/vessel-registration/transfer", element: <VesselTransferPage /> },
// 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