mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: update routing and navigation for Basic Training Certificate and Seaman Book applications
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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}>5–7 working days</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Processing time
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
5–7 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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user