-
- } p="xs">
-
- Your certificate will be reviewed by an EMA Medical Officer within 2 working days.
- Notifications will be sent by email and SMS.
-
-
-
- }
- onClick={handleSubmit}
- loading={submitting}
- disabled={!uploadedFile || !issuedDate || !expiryDate}
- >
- Submit for Verification
-
-
-
-
-
- {/* Notification schedule */}
-
-
-
-
-
- Expiry Notification Schedule
-
-
- } title="90 Days Before Expiry">
- First reminder — time to book your medical examination
-
- } title="60 Days Before Expiry">
- Second reminder — urgent renewal required
-
- } title="30 Days Before Expiry">
- Final reminder — certificate expires very soon
-
-
-
-
- {/* History */}
- {history.length > 0 && (
-
- Certificate History
-
- {history.map((cert) => (
-
-
-
-
-
-
-
- {paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}
- PDF, JPG or PNG — max 5MB
-
-
- {paymentFile ? (
-
-
- {paymentFile.name}
- { setPaymentFile(null); payResetRef.current?.(); }}>
-
-
-
- ) : (
-
- {(props) => (
- } fullWidth {...props}>
- Upload Receipt
-
- )}
-
- )}
-
- >
- )}
-
- )}
-
- {/* ── Step 4: Review ──────────────────────────────────────────── */}
- {active === 3 && (
-
- }>
- Submitting this application will initiate processing for both your Seaman Book and Basic Training Certificate (BTC).
-
-
-
- Relevant Certificate
-
-
-
-
-
-
-
-
-
-
- Medical Certificate
-
-
-
-
-
-
-
-
-
-
- Payment
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Navigation */}
-
- navigate('/seaman-book')}>Cancel
-
- {active > 0 && (
- } onClick={prev}>Previous
- )}
- {active < STEPS.length - 1 ? (
- } onClick={next} disabled={!canNext()}>
- Next Step
-
- ) : (
- } onClick={handleSubmit} loading={submitting}>
- Submit Application
-
- )}
-
-
-
-
- );
-}
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 4d04d4063..e62b6ef00 100644
--- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx
+++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx
@@ -1,18 +1,11 @@
-import { useNavigate } from "react-router-dom";
-import {
- useApiQuery,
- useBypassPaymentMutation,
- useGetPaymentCapabilitiesQuery,
-} from "@ema-platform/api";
-import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
import {
Alert,
Badge,
Box,
Button,
- Card,
- Divider,
+ Center,
Group,
+ Loader,
Paper,
SimpleGrid,
Stack,
@@ -22,111 +15,50 @@ import {
Title,
} from "@mantine/core";
import {
- IconAlertCircle,
IconBook2,
- IconCheck,
IconCircleCheck,
IconClock,
+ IconDownload,
IconFileDescription,
- IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
- IconX,
} from "@tabler/icons-react";
-
-interface ApplicationSummary {
- id: string;
- applicationId: string;
- status: string;
- submittedAt: string;
- /** Set once an officer schedules the pickup date, ahead of CERTIFICATE_ISSUED. */
- scheduledIssuanceDate: string | null;
-}
-
-/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
-interface SeamanBookOverview {
- application: ApplicationSummary | null;
- /**
- * The Basic Training Certificate opened alongside the book by an approved
- * seafarer registration — a separate application, separately numbered and
- * separately billed, so it is shown as its own card rather than merged in.
- */
- btcApplication: ApplicationSummary | null;
- book: {
- id: string;
- issuedDate: string;
- expiryDate: string;
- status: string;
- } | null;
- eligibility: {
- hasProfile: boolean;
- hasSeafarerNumber: boolean;
- hasMedical: boolean;
- medicalExpiry: string | null;
- bstComplete: boolean;
- bstModules: { key: string; label: string; done: boolean }[];
- };
- eligible: boolean;
-}
+import { notifications } from "@mantine/notifications";
+import {
+ SEAFARER_DOCUMENT_KIND_LABELS,
+ SEAFARER_DOCUMENT_STATUS_COLORS,
+ SEAFARER_DOCUMENT_STATUS_LABELS,
+ extractErrorMessage,
+ useBypassDocumentPaymentMutation,
+ useGetMySeafarerDocumentsQuery,
+ useGetPaymentCapabilitiesQuery,
+ useLazyGetMySeafarerDocumentDownloadQuery,
+ type SeafarerDocument,
+ type SeafarerDocumentStatus,
+} from "@ema-platform/api";
+import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
/**
- * The stages an application passes through, for the progress stepper.
- *
- * Derived from the application's status rather than stored as a timeline:
- * the status is what the workflow actually moves, so a second record of the
- * same journey would only drift out of step with it.
+ * The stages a document passes through, for the progress stepper. Derived
+ * from the status the API moves, never stored separately.
*/
-const STAGES: { label: string; statuses: string[] }[] = [
- {
- 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.
+const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
+ { label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
+ { label: "Payment", statuses: ["PAYMENT_PENDING"] },
+ { label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
{ label: "Pickup Scheduled", statuses: ["SCHEDULED"] },
- { label: "Issued", statuses: ["CERTIFICATE_ISSUED", "COMPLETED"] },
+ { label: "Issued", statuses: ["ISSUED"] },
];
-/** How far along the stepper a status sits; -1 for a draft. */
-function stageIndexFor(status: string | undefined): number {
- if (!status || status === "DRAFT") return -1;
+function stageIndexFor(status: SeafarerDocumentStatus): number {
let reached = -1;
STAGES.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i;
});
- // A status past the last named stage (e.g. REJECTED) still shows the
- // journey taken rather than collapsing the stepper to nothing.
return reached;
}
-// Keyed by the workflow's own status values, not display strings: the badge
-// 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",
-};
-
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("en-GB", {
day: "2-digit",
@@ -135,42 +67,23 @@ function formatDate(value: string): string {
});
}
-function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
- return (
-
-
- {ok ? : }
-
-
- {label}
-
-
- );
-}
+/** One document: where it stands, what the applicant can do about it now. */
+function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onChanged: () => void }) {
+ const { payDocument, isPaying } = useApplicationPayment();
+ const { data: capabilities } = useGetPaymentCapabilitiesQuery();
+ const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
+ const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
+ const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
+ const activeStep = stageIndexFor(document.status);
-/**
- * One in-flight application: its number, where it stands, and the stages left.
- *
- * Shared by the Seaman Book and the BTC because an approved registration opens
- * both and they move independently — the book waits on a TRB inspection while
- * the BTC goes straight to payment, so a single merged card would have to lie
- * about one of them.
- */
-function ApplicationCard({
- title,
- application,
- children,
-}: {
- title: string;
- application: ApplicationSummary;
- children?: React.ReactNode;
-}) {
- const activeStep = stageIndexFor(application.status);
+ async function download() {
+ try {
+ const { url } = await getDownload(document.id).unwrap();
+ window.open(url, "_blank", "noopener");
+ } catch (err) {
+ notifications.show({ color: "red", title: "Download failed", message: extractErrorMessage(err) });
+ }
+ }
return (
@@ -181,111 +94,113 @@ function ApplicationCard({
- {title} — {application.id}
+ {title} — {document.documentNumber ?? document.requestNumber}
- {/* 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"}{" "}
- {formatDate(application.submittedAt)}
+ Requested {formatDate(document.createdAt)}
+ {document.feeAmount !== null && ` · Fee ${document.feeAmount} ${document.feeCurrency}`}
-
- {application.status.replaceAll("_", " ")}
+
+ {SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
-
- {STAGES.map((stage, i) => (
-
- ) : (
-
- )
- }
- />
- ))}
-
+ {document.status !== "REJECTED" && document.status !== "CANCELLED" && (
+
+ {STAGES.map((stage, i) => (
+ : }
+ />
+ ))}
+
+ )}
- {children}
+ {document.status === "AWAITING_REGISTRATION" && (
+ } mt="md">
+ Requested with your seafarer registration. It moves to payment as soon as the
+ registration is approved.
+
+ )}
+ {document.status === "PAYMENT_PENDING" && (
+
+ payDocument(document.id)}>
+ Pay now
+
+ {capabilities?.bypassEnabled && (
+ {
+ await bypass(document.id).unwrap();
+ onChanged();
+ }}
+ >
+ Complete test payment
+
+ )}
+
+ )}
+ {(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
+ } mt="md">
+ Payment received. The Authority will schedule a date for you to collect your {title}.
+
+ )}
+ {document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
+ } mt="md">
+ Your {title} is ready for collection on{" "}
+ {formatDate(document.scheduledIssuanceDate)}. Please visit the EMA
+ office on that date, bringing your National ID.
+
+ )}
+ {document.status === "ISSUED" && (
+ } mt="md">
+
+
+ Your {title} {document.documentNumber} was issued
+ {document.issueDate && ` on ${formatDate(document.issueDate)}`}
+ {document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
+
+ } loading={downloading} onClick={download}>
+ Download PDF
+
+
+
+ )}
+ {document.status === "REJECTED" && (
+ } mt="md">
+ {document.rejectionReason ?? "This request was rejected."}
+
+ )}
);
}
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-export function SeamanBookPage({
- service = "COMBINED",
-}: {
- service?: "COMBINED" | "SEAMAN_BOOK" | "BTC";
-}) {
- const navigate = useNavigate();
- const { pay, isPaying } = useApplicationPayment();
+/**
+ * The Seaman Book and Basic Training Certificate — requested automatically
+ * with the seafarer registration, tracked here through payment, collection
+ * and issue.
+ */
+export function SeamanBookPage({ service = "COMBINED" }: { service?: "COMBINED" | "SEAMAN_BOOK" | "BTC" }) {
+ // Polled: payment confirmation, scheduling and issue happen in other sessions.
+ const { data, isLoading, refetch } = useGetMySeafarerDocumentsQuery(undefined, {
+ pollingInterval: 15_000,
+ });
const isBtc = service === "BTC";
const isCombined = service === "COMBINED";
-
- // Polled, not fetch-once: the officer who claims/reviews/approves this
- // application (and the auto-promotion when the parent seafarer
- // registration is approved) all happen in a different session, so nothing
- // in this tab would otherwise tell RTK Query the status changed underneath
- // it — the applicant would see a stale "Draft"/"Payment Pending" until they
- // manually reloaded. `useApiQuery` is a generic untagged passthrough (many
- // unrelated callers share it), so polling this one call is the fix that
- // doesn't risk over-invalidating everyone else's cache.
- const { data, isLoading, refetch } = useApiQuery(
- {
- url: "/seaman-book/my",
- method: "GET",
- },
- { pollingInterval: 15_000 },
- );
- 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;
- const eligibility = data?.eligibility;
- const bstItems = eligibility?.bstModules ?? [];
- const bstDone = bstItems.filter((b) => b.done).length;
-
- // The server decides: the same checklist gates the submission, so a screen
- // that judged eligibility for itself could offer a button the API refuses.
- const isEligible = data?.eligible ?? false;
- // 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(
- isCombined
- ? application || btcApplication
- : isBtc
- ? btcApplication
- : application,
- );
+ const shown = [
+ ...(isCombined || !isBtc ? [data?.seamanBook] : []),
+ ...(isCombined || isBtc ? [data?.btc] : []),
+ ].filter((d): d is SeafarerDocument => Boolean(d));
return (
- {/* Header */}
- My Application —{" "}
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
@@ -293,348 +208,42 @@ export function SeamanBookPage({
: "Seaman Book"}
- {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."}
+ Both are requested for you when you register as a seafarer and released to payment once
+ the registration is approved. Each is paid for separately.
- {/* Active application status — one card per service in flight. */}
- {(isCombined || !isBtc) && application && (
-
- {application.status === "PAYMENT_PENDING" && (
-
- pay(application.applicationId)}
- >
- Pay now
-
- {paymentCapabilities?.bypassEnabled && (
- completeTestPayment(application.applicationId)}
- >
- Complete test payment
-
- )}
-
- )}
- {data?.book ? (
- }
- mt="md"
- >
- Your Seaman Book {data.book.id} has been issued.
- Please visit the EMA office to collect it, bringing your National
- ID.
-
- ) : (
- 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.
-
- )
- )}
-
- )}
- {(isCombined || isBtc) && btcApplication && (
-
- {btcApplication.status === "PAYMENT_PENDING" && (
-
- pay(btcApplication.applicationId)}
- >
- Pay now
-
- {paymentCapabilities?.bypassEnabled && (
-
- completeTestPayment(btcApplication.applicationId)
- }
- >
- Complete test payment
-
- )}
-
- )}
- {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.
-
- )}
-
+ {isLoading ? (
+
+
+
+ ) : shown.length === 0 ? (
+ }>
+ Nothing requested yet. Complete and submit your seafarer registration — a Seaman Book and a
+ Basic Training Certificate are applied for with it.
+
+ ) : (
+ shown.map((document) => )
)}
- {/* No active application — eligibility + apply */}
- {!submitted && (
-
- {/* Eligibility checklist */}
-
-
-
-
-
- Eligibility Requirements
-
-
-
-
-
-
-
-
- {bstItems.map((item) => (
-
- ))}
-
- {!isLoading && !isEligible && (
- }
- 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.
-
-
- )}
-
-
-
- {/* Application form */}
-
-
-
-
-
- New Application
-
-
-
-
- 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:
-
-
- {[
- "Full seafarer profile",
- "National ID / Fayda authenticity",
- "Medical certificate validity",
- "All 5 Basic Safety Training certificates",
- "Passport size photo",
- ].map((item) => (
-
-
- {item}
-
- ))}
-
-
-
-
-
-
-
-
-