feat: update routing and navigation for Basic Training Certificate and Seaman Book applications

This commit is contained in:
nati14575
2026-08-19 12:58:24 +03:00
parent f97eb68e25
commit 548846a1bd
5 changed files with 396 additions and 128 deletions

View File

@@ -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 (
<Container size="xl" py="md">
@@ -694,6 +701,25 @@ export function LicenseReviewPage() {
</Badge>
)}
</Group>
{linkedBookServices.length > 1 && (
<Group gap="xs" mt="xs">
{linkedBookServices.map((related) => (
<Badge
key={related.id}
variant={related.id === app.id ? "filled" : "outline"}
color={STATUS_COLORS[related.status]}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/licence-review/${related.id}`)}
>
{related.licenseType?.key === "SEAMAN_BOOK"
? "Seaman Book"
: "BTC"}{" "}
· {related.applicationNumber} ·{" "}
{STATUS_LABELS[related.status]}
</Badge>
))}
</Group>
)}
</div>
<Group gap="xs">
<Tooltip
@@ -1186,13 +1212,16 @@ export function LicenseReviewPage() {
disabled={!issuanceDate}
aria-label={t("review.schedule", "Schedule")}
onClick={() =>
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"),
)
}
>
<IconCheck size={18} />

View File

@@ -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<string, string> = {
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 (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
<ThemeIcon
size={22}
radius="xl"
variant={ok ? "filled" : "light"}
color={ok ? "teal" : "red"}
>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
<Text fz="sm" c={ok ? undefined : "dimmed"}>
{label}
</Text>
</Group>
);
}
@@ -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)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
color={STATUS_COLOR[application.status] ?? "gray"}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
{application.status.replaceAll("_", " ")}
</Badge>
</Group>
@@ -189,7 +206,7 @@ function ApplicationCard({
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
description={i <= activeStep ? "Done" : "Pending"}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
@@ -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<SeamanBookOverview>({
url: '/seaman-book/my',
method: 'GET',
const { data, isLoading, refetch } = useApiQuery<SeamanBookOverview>({
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 (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Title order={3}>
My Application {" "}
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
? "Basic Training Certificate"
: "Seaman Book"}
</Title>
<Text fz="sm" c="dimmed">
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."}
</Text>
</div>
{/* Active application status — one card per service in flight. */}
{application && (
{(isCombined || !isBtc) && application && (
<ApplicationCard title="Seaman Book" application={application}>
{application.status === "PAYMENT_PENDING" && (
<Group mt="md">
<Button
loading={isPaying}
onClick={() => pay(application.applicationId)}
>
Pay now
</Button>
{paymentCapabilities?.bypassEnabled && (
<Button
variant="default"
loading={bypassingPayment}
onClick={() => completeTestPayment(application.applicationId)}
>
Complete test payment
</Button>
)}
</Group>
)}
{data?.book ? (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
<Alert
variant="light"
color="teal"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Seaman Book <strong>{data.book.id}</strong> 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.
</Alert>
) : (
application.status === 'SCHEDULED' &&
application.status === "SCHEDULED" &&
application.scheduledIssuanceDate && (
<Alert variant="light" color="grape" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book is ready for collection on{' '}
<strong>{formatDate(application.scheduledIssuanceDate)}</strong>.
Please visit the EMA office on that date, bringing your National ID.
<Alert
variant="light"
color="grape"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Seaman Book is ready for collection on{" "}
<strong>{formatDate(application.scheduledIssuanceDate)}</strong>
. Please visit the EMA office on that date, bringing your
National ID.
</Alert>
)
)}
</ApplicationCard>
)}
{btcApplication && (
<ApplicationCard title="Basic Training Certificate" application={btcApplication}>
{btcApplication.status === 'SCHEDULED' && btcApplication.scheduledIssuanceDate && (
<Alert variant="light" color="grape" icon={<IconPrinter size={17} />} mt="md">
Your Basic Training Certificate is ready for collection on{' '}
<strong>{formatDate(btcApplication.scheduledIssuanceDate)}</strong>.
Please visit the EMA office on that date, bringing your National ID.
</Alert>
{(isCombined || isBtc) && btcApplication && (
<ApplicationCard
title="Basic Training Certificate"
application={btcApplication}
>
{btcApplication.status === "PAYMENT_PENDING" && (
<Group mt="md">
<Button
loading={isPaying}
onClick={() => pay(btcApplication.applicationId)}
>
Pay now
</Button>
{paymentCapabilities?.bypassEnabled && (
<Button
variant="default"
loading={bypassingPayment}
onClick={() =>
completeTestPayment(btcApplication.applicationId)
}
>
Complete test payment
</Button>
)}
</Group>
)}
{btcApplication.status === "SCHEDULED" &&
btcApplication.scheduledIssuanceDate && (
<Alert
variant="light"
color="grape"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Basic Training Certificate is ready for collection on{" "}
<strong>
{formatDate(btcApplication.scheduledIssuanceDate)}
</strong>
. Please visit the EMA office on that date, bringing your
National ID.
</Alert>
)}
</ApplicationCard>
)}
@@ -280,7 +392,12 @@ export function SeamanBookPage() {
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<ThemeIcon
variant="light"
color={isEligible ? "teal" : "orange"}
size={36}
radius="md"
>
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
@@ -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) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
<EligibilityItem
key={item.key}
label={item.label}
ok={item.done}
/>
))}
{!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Alert
variant="light"
color="orange"
icon={<IconAlertCircle size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
Complete all requirements above before applying.
{bstItems.length > bstDone
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
: ''}
: ""}
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
<Alert
variant="light"
color="teal"
icon={<IconCircleCheck size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
You meet all requirements. You may proceed with your
application.
</Text>
</Alert>
)}
</Stack>
@@ -343,24 +479,30 @@ export function SeamanBookPage() {
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
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.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Text fw={600} fz="sm">
What will be verified:
</Text>
<Stack gap={6}>
{[
'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) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<IconCircleCheck
size={15}
color="var(--mantine-color-teal-6)"
/>
<Text fz="sm">{item}</Text>
</Group>
))}
@@ -373,8 +515,12 @@ export function SeamanBookPage() {
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
<Text fz="xs" c="dimmed">
Processing time
</Text>
<Text fz="sm" fw={600}>
57 working days
</Text>
</div>
</Group>
</Card>
@@ -382,22 +528,38 @@ export function SeamanBookPage() {
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
<Text fz="xs" c="dimmed">
Medical validity
</Text>
<Text fz="sm" fw={600}>
2 years (STCW)
</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={15} />}
p="xs"
>
<Text fz="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.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
onClick={() =>
navigate(
isBtc
? "/licensing/BTC_BASIC_TRAINING/apply"
: "/seaman-book/apply",
)
}
disabled={!isEligible}
size="md"
>
@@ -418,20 +580,62 @@ export function SeamanBookPage() {
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
<Text fw={700} fz="sm">
About the{" "}
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
? "Basic Training Certificate"
: "Seaman Book"}
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
{(isBtc
? [
{
icon: IconShield,
title: "STCW Training",
desc: "Confirms completion of the required basic maritime safety training.",
},
{
icon: IconFileDescription,
title: "Certificate Record",
desc: "Keeps your approved basic training evidence available in one place.",
},
{
icon: IconCircleCheck,
title: "Verified",
desc: "Issued after EMA verifies the applicable training requirements.",
},
]
: [
{
icon: IconBook2,
title: "Official Identity",
desc: "Internationally recognized maritime identity document required before joining any vessel.",
},
{
icon: IconFileDescription,
title: "Service Record",
desc: "Records all your sea service, vessel assignments, and employment history.",
},
{
icon: IconShield,
title: "STCW Compliance",
desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.",
},
]
).map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
<Text fz="sm" fw={600}>
{title}
</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
<Text fz="xs" c="dimmed" lh={1.5}>
{desc}
</Text>
</Box>
))}
</SimpleGrid>

View File

@@ -114,7 +114,7 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
},
{
to: "/licensing/BTC_BASIC_TRAINING/apply",
to: "/basic-training-certificate",
label: "Basic Training Certificate",
i18nKey: "nav.btc",
icon: IconShieldCheck,
@@ -199,6 +199,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
"/seafarer/records": { i18nKey: "nav.seaRecords" },
"/seaman-book": { i18nKey: "nav.myApplication" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },

View File

@@ -122,6 +122,24 @@ export const router = createBrowserRouter([
{ path: "/payments/check", element: <PaymentCheckPage /> },
{ path: "/payments/success", element: <PaymentSuccessPage /> },
{ path: "/payments/failure", element: <PaymentFailurePage /> },
// Seaman Book and BTC are auto-opened together after seafarer approval.
// They use the shared status/payment page, never the generic form wizard.
{
path: "/licensing/BTC_BASIC_TRAINING/apply",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/BTC_BASIC_TRAINING/applications/:applicationId",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/apply",
element: <Navigate to="/seaman-book" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/applications/:applicationId",
element: <Navigate to="/seaman-book" replace />,
},
{
path: "/licensing/:typeCode/apply",
element: (
@@ -169,7 +187,9 @@ export const router = createBrowserRouter([
{
path: "/seafarer/records",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<MySeaRecordsPage />
</RequirePermission>
),
@@ -198,7 +218,17 @@ export const router = createBrowserRouter([
{
path: "/seaman-book",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<SeamanBookPage />
</RequirePermission>
),
},
{
path: "/basic-training-certificate",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage />
</RequirePermission>
),
@@ -206,7 +236,9 @@ export const router = createBrowserRouter([
{
path: "/seaman-book/apply",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<SeamanBookApplicationPage />
</RequirePermission>
),
@@ -259,7 +291,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registration",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -277,7 +311,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-ownership-transfer",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselTransferPage />
</RequirePermission>
),
@@ -378,7 +414,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -387,7 +425,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations/:id",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationStatusPage />
</RequirePermission>
),

View File

@@ -2,7 +2,7 @@
// Reused rather than redeclared: the department vocabulary belongs to the
// seafarer domain, and two copies would drift.
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
import type { SeafarerDepartment } from "../seafarer/seafarer.types";
/**
* A backend `LocaleValidationDto`. Named for the two locales the UI offers, but
@@ -102,12 +102,12 @@ export interface FormSectionConfig {
/** Grouping the portal organises the licence catalogue by. */
export type LicenseCategory =
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT'
| 'MARITIME_PERSONNEL'
| 'VESSEL_SERVICES'
| 'WAIVER_SERVICES';
| "CARGO_FREIGHT"
| "SHIPPING_AGENCY"
| "INVESTMENT"
| "MARITIME_PERSONNEL"
| "VESSEL_SERVICES"
| "WAIVER_SERVICES";
export interface LicenseCategoryDefinition {
key: LicenseCategory;
@@ -187,11 +187,7 @@ export interface LicenseType {
/** What kind of document a certificate type produces. */
export type CertificateCategory =
| "COC"
| "COP"
| "ENDORSEMENT"
| "GOC"
| "NATIONAL";
"COC" | "COP" | "ENDORSEMENT" | "GOC" | "NATIONAL";
/**
* STCW responsibility level. Cadet is absent by design — under STCW a cadet is
@@ -262,6 +258,7 @@ export interface LicenseApplication {
licenseTypeId: string;
licenseType?: LicenseType;
applicantUserId: string;
parentApplicationId?: string | null;
kind: ApplicationKind;
status: LicenseStatus;
assignedOfficerId: string | null;
@@ -373,6 +370,7 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
staff: ApplicationStaff[];
@@ -489,11 +487,7 @@ export interface TemplatePageOptions {
/** Corner the institute logo is anchored to. */
export type TemplateLogoCorner =
| 'TOP_LEFT'
| 'TOP_CENTER'
| 'TOP_RIGHT'
| 'BOTTOM_LEFT'
| 'BOTTOM_RIGHT';
"TOP_LEFT" | "TOP_CENTER" | "TOP_RIGHT" | "BOTTOM_LEFT" | "BOTTOM_RIGHT";
/** Where the institute logo sits on the certificate. */
export interface TemplateLogoPlacement {
@@ -520,8 +514,8 @@ export interface TemplateFieldPlacement {
yPct: number;
widthPct: number;
fontSize?: number;
fontWeight?: 'normal' | 'bold';
align?: 'left' | 'center' | 'right';
fontWeight?: "normal" | "bold";
align?: "left" | "center" | "right";
color?: string;
}