Adding more functionalities for all the license types

This commit is contained in:
Mulu Mehari
2026-08-07 11:50:07 +03:00
parent 35fb817b4b
commit 7c968c7093
66 changed files with 6468 additions and 3518 deletions

View File

@@ -1,20 +1,278 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import {
Alert,
Badge,
Button,
Card,
Group,
List,
Loader,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconCertificate,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaTimeQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
const CERTIFICATE_TYPE_KEYS = [
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
];
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
return (
<List.Item
icon={
<ThemeIcon
color={ok ? 'teal' : 'red'}
variant="light"
size="sm"
radius="xl"
>
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
</ThemeIcon>
}
>
{label}
</List.Item>
);
}
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
* CoC / CoP home (US-CERT-002…004, 009): eligibility at a glance, the two
* application entry points, and the seafarer's certificate applications and
* issued certificates. The wizard itself is the config-driven licensing flow.
*/
export function CertificatesPage() {
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: seaTime } = useGetMySeaTimeQuery();
const { data: medicals } = useGetMyMedicalCertificatesQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
const today = new Date().toISOString().slice(0, 10);
const hasMedical = (medicals ?? []).some(
(certificate) =>
certificate.status !== 'REJECTED' && certificate.expiryDate >= today,
);
const verifiedDays = seaTime?.totalDays ?? 0;
const certificateApplications = (applications?.items ?? []).filter((app) =>
CERTIFICATE_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = certificateApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const issued = (licenses?.items ?? []).filter((license) =>
CERTIFICATE_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch certificate'));
}
}
if (loadingProfile || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="My certificates"
description="Seafarer certificates are not connected to the backend yet."
/>
</Container>
<Stack maw={860} mx="auto">
<Title order={2}>My Certificates</Title>
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} mb={6}>
Eligibility
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? `Registered seafarer (${profile?.seafarerNumber})`
: 'Active seafarer registration required'
}
/>
<EligibilityItem
ok={hasMedical}
label={
hasMedical
? 'Current medical certificate on file'
: 'A current medical certificate is required'
}
/>
<EligibilityItem
ok={verifiedDays > 0}
label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`}
/>
</List>
</div>
<Stack gap="xs">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')
}
>
Apply for CoC
</Button>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')
}
>
Apply for CoP
</Button>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
Complete your{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
seafarer registration
</Text>{' '}
first certificate applications are refused without it.
</Alert>
)}
</Card>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
{inFlight.map((app) => (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Text size="xs" c="dimmed">
{app.licenseType?.name?.en}
</Text>
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant="light"
onClick={() =>
navigate(
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<Stack gap="xs">
<Title order={4}>Issued certificates</Title>
{issued.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No certificates issued yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate </Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{issued.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => download(license.id)}
>
Download
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
</Stack>
);
}

View File

@@ -1,21 +1,8 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { Navigate } from 'react-router-dom';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
/** The CoC wizard is the config-driven licensing flow; keep the old URL alive. */
export function CoCApplicationPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Apply for a CoC"
description="Certificate of Competency applications are not connected to the backend yet."
/>
</Container>
);
return <Navigate to="/licensing/CERTIFICATE_OF_COMPETENCY/apply" replace />;
}
export default CoCApplicationPage;

View File

@@ -0,0 +1,406 @@
import { useState } from 'react';
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
useApiQuery,
useApiMutation,
extractErrorMessage,
openAuthedDocument,
} from '@ema-platform/api';
interface OpenExam {
id: string;
title: { en?: string; am?: string };
date: string;
venue: string | null;
status: string;
certification?: { name?: { en?: string } };
}
type AttendanceStatus =
| 'REGISTERED'
| 'PRESENT'
| 'ABSENT'
| 'LATE'
| 'WITHDRAWN'
| 'DISQUALIFIED';
interface MyRegistration {
id: string;
admissionNumber: string;
createdAt: string;
kind: 'NEW' | 'RETAKE';
attemptNumber: number;
attendanceStatus: AttendanceStatus;
exam?: OpenExam;
}
interface MyResult {
id: string;
totalScore: number;
status: 'PASSED' | 'FAILED';
publishedAt: string | null;
remark?: { en?: string } | null;
exam?: OpenExam;
}
interface MyAppeal {
id: string;
appealNumber: string;
status: 'SUBMITTED' | 'UNDER_REVIEW' | 'UPHELD' | 'REJECTED';
reason: string;
decisionRemark: string | null;
resultId: string;
}
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
REGISTERED: 'gray',
PRESENT: 'teal',
LATE: 'yellow',
ABSENT: 'red',
WITHDRAWN: 'orange',
DISQUALIFIED: 'red',
};
/**
* The candidate's examination home (US-EXAM-007/008/014/015/016): sessions to
* sit, the admission slip to print, published results, and the appeal route
* when a mark looks wrong.
*/
export function ExamsPage() {
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
const [appealReason, setAppealReason] = useState('');
const { data: open, isLoading: loadingOpen } = useApiQuery<OpenExam[]>({
url: '/exams/open',
method: 'GET',
});
const {
data: mine,
isLoading: loadingMine,
refetch,
} = useApiQuery<MyRegistration[]>({
url: '/exams/registrations/mine',
method: 'GET',
});
const { data: results, isLoading: loadingResults } = useApiQuery<MyResult[]>({
url: '/results/mine',
method: 'GET',
});
const {
data: appeals,
refetch: refetchAppeals,
} = useApiQuery<MyAppeal[]>({ url: '/results/appeals/mine', method: 'GET' });
const [registerTrigger, { isLoading: registering }] = useApiMutation();
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
const register = async (exam: OpenExam) => {
try {
const result = (await registerTrigger({
url: `/exams/${exam.id}/register`,
method: 'POST',
}).unwrap()) as { admissionNumber?: string };
notify.success(
`Registered — admission number ${result.admissionNumber ?? 'issued'}`,
);
refetch();
} catch (error) {
const key = extractErrorMessage(error, 'Could not register');
notify.error(
key === 'seafarer_registration_required'
? 'An active seafarer registration is required to sit examinations.'
: key === 'already_registered_for_exam'
? 'You are already registered for this session.'
: key === 'subject_already_passed'
? 'You have already passed this subject — a resit is not needed.'
: key,
);
}
};
const downloadSlip = async (registration: MyRegistration) => {
try {
await openAuthedDocument(
`/exams/registrations/${registration.id}/slip`,
`admission-${registration.admissionNumber}.pdf`,
);
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not generate the admission slip'),
);
}
};
const submitAppeal = async () => {
if (!appealFor) return;
try {
const appeal = (await appealTrigger({
url: `/results/${appealFor.id}/appeal`,
method: 'POST',
body: { reason: appealReason.trim() },
}).unwrap()) as { appealNumber?: string };
notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`);
setAppealFor(null);
setAppealReason('');
refetchAppeals();
} catch (error) {
const key = extractErrorMessage(error, 'Could not submit the appeal');
notify.error(
key.startsWith('appeal_window_closed')
? `The appeal window (${key.split(':')[1] ?? ''} days from publication) has closed.`
: key === 'appeal_already_open'
? 'An appeal on this result is already being considered.'
: key,
);
}
};
if (loadingOpen || loadingMine || loadingResults) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack maw={900} mx="auto">
<Title order={2}>Examinations</Title>
<Stack gap="xs">
<Title order={4}>Open sessions</Title>
{(open ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No upcoming sessions are open for registration.
</Text>
</Card>
) : (
(open ?? []).map((exam) => (
<Card key={exam.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{exam.title?.en}</Text>
<Text size="xs" c="dimmed">
{exam.certification?.name?.en ?? ''} ·{' '}
{exam.date?.slice(0, 10)}
{exam.venue ? ` · ${exam.venue}` : ''}
</Text>
</div>
{registeredExamIds.has(exam.id) ? (
<Badge color="teal" variant="light">
Registered
</Badge>
) : (
<Button
size="compact-sm"
loading={registering}
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
</Button>
)}
</Group>
</Card>
))
)}
</Stack>
<Stack gap="xs">
<Title order={4}>My registrations</Title>
{(mine ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No exam registrations yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Admission </Table.Th>
<Table.Th>Examination</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Venue</Table.Th>
<Table.Th>Attempt</Table.Th>
<Table.Th>Attendance</Table.Th>
<Table.Th>Slip</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(mine ?? []).map((registration) => (
<Table.Tr key={registration.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{registration.admissionNumber}
</Text>
</Table.Td>
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{registration.exam?.date?.slice(0, 10)}</Table.Td>
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{registration.kind === 'RETAKE'
? `Retake · ${registration.attemptNumber}`
: 'First sitting'}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={
ATTENDANCE_COLOR[registration.attendanceStatus] ??
'gray'
}
>
{registration.attendanceStatus}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => downloadSlip(registration)}
>
Slip
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
<Stack gap="xs">
<Title order={4}>My results</Title>
{(results ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No results have been published yet. Marks appear here once the
authority approves and publishes them.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Examination</Table.Th>
<Table.Th>Published</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>Appeal</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(results ?? []).map((result) => {
const appeal = (appeals ?? []).find(
(a) => a.resultId === result.id,
);
return (
<Table.Tr key={result.id}>
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{result.totalScore}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={result.status === 'PASSED' ? 'teal' : 'red'}
>
{result.status}
</Badge>
</Table.Td>
<Table.Td>
{appeal ? (
<Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status}
</Badge>
) : (
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<IconGavel size={13} />}
onClick={() => setAppealFor(result)}
>
Appeal
</Button>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
<Modal
opened={Boolean(appealFor)}
onClose={() => setAppealFor(null)}
title="Request a review of this result"
radius="lg"
>
<Stack>
<Text size="sm" c="dimmed">
Explain what you believe went wrong with the marking or the
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
Appeals must be lodged within 14 days of publication.
</Text>
<Textarea
minRows={4}
autosize
label="Grounds for appeal"
value={appealReason}
onChange={(event) => setAppealReason(event.currentTarget.value)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setAppealFor(null)}>
Cancel
</Button>
<Button
loading={appealing}
disabled={appealReason.trim().length === 0}
onClick={submitAppeal}
>
Submit appeal
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
export default ExamsPage;

View File

@@ -41,6 +41,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
// Filtered out of this catalogue (requiresOperatorMode is false), listed
// only so the record stays total if that ever changes.
MARITIME_PERSONNEL: IconShip,
};
function formatFee(amount: string | number | null, currency: string): string {
@@ -69,6 +72,10 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Person-centric registrations (seafarer) are not operator licences:
// they can never be declared as a mode, have their own entry points,
// and would only confuse this catalogue — even under "show all".
.filter((t) => t.requiresOperatorMode !== false)
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.

View File

@@ -133,7 +133,10 @@ export function LicenseApplicationPage() {
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
const steps = useMemo(
() => buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft),
() =>
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
}),
[config, draft],
);
const sections = useMemo(

View File

@@ -4,21 +4,70 @@ import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
/**
* Sends an applicant who has not said what they operate as to the step that
* asks. Everything else in the portal is keyed off that answer — the catalogue
* offers nothing without it, and the server refuses an application for a mode
* the profile does not hold — so it is asked once, up front.
* asks. The logistics licence catalogue is keyed off that answer — it offers
* nothing without it, and the server refuses an application for a mode the
* profile does not hold — so it is asked once, up front.
*
* Deliberately not a hard gate on every route: the profile and support pages
* stay reachable, because someone who cannot answer the question yet must
* still be able to reach their account and ask for help.
* The gate is deliberately not portal-wide. Seafarer and vessel services are
* seeded `requiresOperatorMode: false` precisely because a seafarer holds no
* logistics mode of operation; gating them on this question locked the people
* those services exist for out of registration, sea records, certificates and
* examinations. Profile and support stay open for the same reason: someone who
* cannot answer the question yet must still reach their account and ask for
* help.
*/
const ALWAYS_ALLOWED = ['/onboarding/operations', '/profile', '/support'];
const ALWAYS_ALLOWED = [
'/onboarding/operations',
'/profile',
'/support',
'/notifications',
// Seafarer services — no operator mode required by the licence types behind
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
'/seafarer-registration',
'/seafarer/records',
'/seafarer-registry',
'/exams',
'/certificates',
'/seaman-book',
'/endorsements',
'/documents',
// Vessel services — VESSEL_REGISTRATION is likewise mode-free.
'/vessel-registration',
// Waivers are filed by importers, who hold no logistics operating licence.
'/waiver',
];
/**
* Licence types seeded `requiresOperatorMode: false`.
*
* The wizard lives at one shared route (`/licensing/:typeCode/apply`), so the
* gate has to know which types behind it are mode-free — otherwise a seafarer
* or an importer reaches their service page and is bounced the moment they
* start the application. Mirrors the seeds; the server is still the authority,
* refusing a create for a mode the profile does not hold.
*/
const MODE_FREE_TYPE_KEYS = [
'SEAFARER_REGISTRATION',
'VESSEL_REGISTRATION',
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
'PRE_WAIVER',
'POST_WAIVER',
];
function isModeFreeLicensingRoute(pathname: string): boolean {
const match = pathname.match(/^\/licensing\/([^/]+)/);
return match ? MODE_FREE_TYPE_KEYS.includes(match[1]) : false;
}
export function RequireOperations({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
if (ALWAYS_ALLOWED.some((path) => pathname.startsWith(path))) {
if (
ALWAYS_ALLOWED.some((path) => pathname.startsWith(path)) ||
isModeFreeLicensingRoute(pathname)
) {
return <>{children}</>;
}

View File

@@ -53,7 +53,12 @@ export function OperationsFormContent({
useEffect(() => setSelected(declaredIds), [declaredIds]);
const options = useMemo(
() => (catalogue?.items ?? []).filter((t) => t.isActive),
() =>
(catalogue?.items ?? [])
.filter((t) => t.isActive)
// A mode of operation is an operator licence; person-centric
// registrations (seafarer) cannot be declared as one.
.filter((t) => t.requiresOperatorMode !== false),
[catalogue],
);

View File

@@ -0,0 +1,765 @@
import {
ActionIcon,
Alert,
Anchor,
Badge,
Button,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAnchor,
IconEdit,
IconFileUpload,
IconInfoCircle,
IconPaperclip,
IconPlus,
IconStethoscope,
IconTrash,
} from '@tabler/icons-react';
import { useState } from 'react';
import { notify } from '@ema-platform/ui';
import {
extractErrorMessage,
uploadDocument,
useCreateMedicalCertificateMutation,
useCreateSeaServiceRecordMutation,
useDeleteMedicalCertificateMutation,
useDeleteSeaServiceRecordMutation,
useGetAttachmentsQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeaTimeQuery,
useUpdateMedicalCertificateMutation,
useUpdateSeaServiceRecordMutation,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
/**
* Evidence viewer/uploader shared by both record kinds.
*
* Files upload against the record itself (`SEA_SERVICE_RECORD` /
* `MEDICAL_CERTIFICATE` owner types), so the officer verifying it later opens
* exactly what the seafarer attached.
*/
function EvidenceModal({
ownerType,
ownerId,
onClose,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE';
ownerId: string | null;
onClose: () => void;
}) {
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [uploading, setUploading] = useState(false);
const upload = async (file: File | null) => {
if (!file || !ownerId) return;
setUploading(true);
const result = await uploadDocument({
ownerType,
ownerId,
documentKey: 'evidence',
file,
});
setUploading(false);
if (result.ok) {
notify.success('Evidence uploaded');
refetch();
} else {
notify.error(result.error);
}
};
const files = (attachments ?? []).flatMap((a) => a.files);
return (
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered>
<Stack>
{isLoading ? (
<Loader size="sm" />
) : files.length === 0 ? (
<Text size="sm" c="dimmed">
No evidence uploaded yet.
</Text>
) : (
files.map((file) => (
<Group key={file.id} gap="xs">
<IconPaperclip size={16} />
{file.url ? (
<Anchor href={file.url} target="_blank" size="sm">
{file.originalName}
</Anchor>
) : (
<Text size="sm">{file.originalName}</Text>
)}
</Group>
))
)}
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = {
vesselName: '',
imoNumber: '',
vesselType: '',
flagState: '',
rank: '',
engagementDate: '',
dischargeDate: '',
dutiesDescription: '',
};
function SeaServiceTab() {
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
const { data: seaTime } = useGetMySeaTimeQuery();
const [createRecord, { isLoading: creating }] =
useCreateSeaServiceRecordMutation();
const [updateRecord, { isLoading: updating }] =
useUpdateSeaServiceRecordMutation();
const [deleteRecord] = useDeleteSeaServiceRecordMutation();
const [editing, setEditing] = useState<SeaServiceRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const openCreate = () => {
setEditing(null);
setForm(EMPTY_SEA_SERVICE);
setGrossTonnage('');
setModalOpen(true);
};
const openEdit = (record: SeaServiceRecord) => {
setEditing(record);
setForm({
vesselName: record.vesselName,
imoNumber: record.imoNumber ?? '',
vesselType: record.vesselType ?? '',
flagState: record.flagState ?? '',
rank: record.rank,
engagementDate: record.engagementDate,
dischargeDate: record.dischargeDate,
dutiesDescription: record.dutiesDescription ?? '',
});
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setModalOpen(true);
};
const save = async () => {
const body = {
vesselName: form.vesselName,
rank: form.rank,
engagementDate: form.engagementDate,
dischargeDate: form.dischargeDate,
...(form.imoNumber ? { imoNumber: form.imoNumber } : {}),
...(form.vesselType ? { vesselType: form.vesselType } : {}),
...(form.flagState ? { flagState: form.flagState } : {}),
...(form.dutiesDescription
? { dutiesDescription: form.dutiesDescription }
: {}),
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
};
try {
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
await createRecord(body).unwrap();
notify.success('Sea-service record added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record'));
}
};
const remove = async (record: SeaServiceRecord) => {
try {
await deleteRecord(record.id).unwrap();
notify.success('Record withdrawn');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not delete the record'));
}
};
const valid =
form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 &&
form.engagementDate &&
form.dischargeDate &&
form.engagementDate < form.dischargeDate;
if (isLoading) return <Loader />;
return (
<Stack>
<Group justify="space-between">
<Group gap="sm">
<Text size="sm" c="dimmed">
Every engagement aboard a vessel, with its evidence. Verified
records feed certificate eligibility.
</Text>
{seaTime && seaTime.verifiedRecords > 0 && (
<Badge variant="light" color="teal">
Approved sea time: {seaTime.totalDays} days
</Badge>
)}
</Group>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
</Button>
</Group>
{(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No sea-service records yet.
</Text>
</Paper>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vessel</Table.Th>
<Table.Th>Rank</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(records ?? []).map((record) => {
const locked = record.status !== 'SUBMITTED';
return (
<Table.Tr key={record.id}>
<Table.Td>
<Text fw={600} size="sm">
{record.vesselName}
</Text>
{record.imoNumber && (
<Text size="xs" c="dimmed">
IMO {record.imoNumber}
</Text>
)}
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>{record.engagementDate}</Table.Td>
<Table.Td>{record.dischargeDate}</Table.Td>
<Table.Td>
<Tooltip
label={record.verificationRemark ?? ''}
disabled={!record.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[record.status]}>
{record.status}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<ActionIcon
variant="subtle"
onClick={() => setEvidenceFor(record.id)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={locked ? 'Verified records are frozen' : 'Edit'}
>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => openEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={locked ? 'Verified records are frozen' : 'Delete'}
>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => remove(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Vessel name"
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/>
<TextInput
label="IMO number"
value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/>
</Group>
<Group grow>
<TextInput
label="Vessel type"
value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/>
<TextInput
label="Flag state"
value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/>
<NumberInput
label="Gross tonnage"
min={0}
value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/>
</Group>
<TextInput
label="Rank / capacity"
required
value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })}
/>
<Group grow>
<TextInput
type="date"
label="Engagement date"
required
value={form.engagementDate}
onChange={(e) =>
setForm({ ...form, engagementDate: e.target.value })
}
/>
<TextInput
type="date"
label="Discharge date"
required
value={form.dischargeDate}
onChange={(e) =>
setForm({ ...form, dischargeDate: e.target.value })
}
/>
</Group>
<Textarea
label="Duties"
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add record'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="SEA_SERVICE_RECORD"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
// -------------------------------------------------------------------- medical
const EMPTY_MEDICAL = {
issuerName: '',
certificateNumber: '',
issueDate: '',
expiryDate: '',
fitnessStatus: 'FIT',
restrictions: '',
};
function MedicalTab() {
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
const [createCertificate, { isLoading: creating }] =
useCreateMedicalCertificateMutation();
const [updateCertificate, { isLoading: updating }] =
useUpdateMedicalCertificateMutation();
const [deleteCertificate] = useDeleteMedicalCertificateMutation();
const [editing, setEditing] = useState<MedicalCertificate | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_MEDICAL);
setModalOpen(true);
};
const openEdit = (certificate: MedicalCertificate) => {
setEditing(certificate);
setForm({
issuerName: certificate.issuerName,
certificateNumber: certificate.certificateNumber ?? '',
issueDate: certificate.issueDate,
expiryDate: certificate.expiryDate,
fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '',
});
setModalOpen(true);
};
const save = async () => {
const body = {
issuerName: form.issuerName,
issueDate: form.issueDate,
expiryDate: form.expiryDate,
fitnessStatus: form.fitnessStatus as MedicalCertificate['fitnessStatus'],
...(form.certificateNumber
? { certificateNumber: form.certificateNumber }
: {}),
...(form.restrictions ? { restrictions: form.restrictions } : {}),
};
try {
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
await createCertificate(body).unwrap();
notify.success('Medical certificate added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
}
};
const remove = async (certificate: MedicalCertificate) => {
try {
await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not delete the certificate'),
);
}
};
const valid =
form.issuerName.trim().length > 1 &&
form.issueDate &&
form.expiryDate &&
form.issueDate < form.expiryDate;
const today = new Date().toISOString().slice(0, 10);
if (isLoading) return <Loader />;
return (
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new
applications that require one.
</Text>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
</Button>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No medical certificates yet.
</Text>
</Paper>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Issuer</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Fitness</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(certificates ?? []).map((certificate) => {
const locked = certificate.status !== 'SUBMITTED';
const expired = certificate.expiryDate < today;
return (
<Table.Tr key={certificate.id}>
<Table.Td>
<Text fw={600} size="sm">
{certificate.issuerName}
</Text>
{certificate.certificateNumber && (
<Text size="xs" c="dimmed">
{certificate.certificateNumber}
</Text>
)}
</Table.Td>
<Table.Td>{certificate.issueDate}</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
{certificate.expiryDate}
{expired && <Badge color="red">Expired</Badge>}
</Group>
</Table.Td>
<Table.Td>
{FITNESS_OPTIONS.find(
(o) => o.value === certificate.fitnessStatus,
)?.label ?? certificate.fitnessStatus}
</Table.Td>
<Table.Td>
<Tooltip
label={certificate.verificationRemark ?? ''}
disabled={!certificate.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[certificate.status]}>
{certificate.status}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<ActionIcon
variant="subtle"
onClick={() => setEvidenceFor(certificate.id)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={
locked ? 'Verified certificates are frozen' : 'Edit'
}
>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => openEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip
label={
locked
? 'Verified certificates are frozen'
: 'Delete'
}
>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => remove(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Issuing clinic / physician"
required
value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/>
<TextInput
label="Certificate number"
value={form.certificateNumber}
onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value })
}
/>
</Group>
<Group grow>
<TextInput
type="date"
label="Issue date"
required
value={form.issueDate}
onChange={(e) => setForm({ ...form, issueDate: e.target.value })}
/>
<TextInput
type="date"
label="Expiry date"
required
value={form.expiryDate}
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
/>
</Group>
<Select
label="Fitness outcome"
data={FITNESS_OPTIONS}
value={form.fitnessStatus}
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
/>
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
<Textarea
label="Restrictions"
value={form.restrictions}
onChange={(e) =>
setForm({ ...form, restrictions: e.target.value })
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add certificate'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="MEDICAL_CERTIFICATE"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
/**
* The seafarer's evidence shelf (US-SSM-001/006): sea-service history and
* medical certificates, each with uploaded evidence, editable until an
* officer verifies them.
*/
export function MySeaRecordsPage() {
return (
<Stack>
<Title order={2}>My Sea Records</Title>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
Records you add here are submitted for EMA verification. Once verified
they are frozen and count toward certificate eligibility.
</Alert>
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical Certificates
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="md">
<SeaServiceTab />
</Tabs.Panel>
<Tabs.Panel value="medical" pt="md">
<MedicalTab />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -1,701 +0,0 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Avatar,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconArrowLeft,
IconBook,
IconBriefcase,
IconCertificate,
IconCheck,
IconClock,
IconEdit,
IconFileText,
IconHeartbeat,
IconHistory,
IconLayoutDashboard,
IconPlus,
IconPrinter,
IconShip,
IconUser,
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
// Extended profile types
// ---------------------------------------------------------------------------
interface TrainingRecord {
id: string;
course: string;
institution: string;
certNo: string;
issueDate: string;
expiry: string;
status: 'Approved' | 'Pending' | 'Expired';
}
interface MedicalRecord {
id: string;
examType: string;
issuedBy: string;
issueDate: string;
expiry: string;
result: 'Fit' | 'Unfit' | 'Conditional';
remarks: string;
}
interface SeaServiceRecord {
id: string;
vesselName: string;
vesselType: string;
rank: string;
flag: string;
from: string;
to: string;
engagementPort: string;
}
interface CertificationRecord {
id: string;
name: string;
certNo: string;
issuedBy: string;
issueDate: string;
expiry: string;
type: string;
status: 'Valid' | 'Expired' | 'Pending';
}
interface HistoryEntry {
id: string;
action: string;
performedBy: string;
date: string;
notes: string;
}
interface SeafarerProfile extends Seafarer {
dob: string;
nationalId: string;
passportNo: string;
bookNumber: string;
permanentAddress: string;
training: TrainingRecord[];
medical: MedicalRecord[];
seaService: SeaServiceRecord[];
certifications: CertificationRecord[];
history: HistoryEntry[];
}
// ---------------------------------------------------------------------------
// Dummy API — replace bodies with real fetch calls
// ---------------------------------------------------------------------------
async function fetchSeafarerProfile(id: string): Promise<SeafarerProfile> {
await new Promise((r) => setTimeout(r, 800));
return {
id,
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
dob: '1988-03-15',
nationalId: 'ET-1234567',
passportNo: 'EP123456',
bookNumber: 'SB-2024-0001',
permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa',
training: [
{ id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' },
{ id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' },
{ id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' },
],
medical: [
{ id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' },
{ id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' },
],
seaService: [
{ id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' },
{ id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' },
],
certifications: [
{ id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' },
{ id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' },
],
history: [
{ id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' },
{ id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' },
{ id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' },
],
};
}
async function updateSeafarerStatus(_id: string, _status: string): Promise<void> {
await new Promise((r) => setTimeout(r, 600));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
}
function InfoField({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={700} fz="sm">{title}</Text>
{action}
</Group>
<Divider mb="md" />
{children}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Overview
// ---------------------------------------------------------------------------
function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) {
return (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<SectionCard title="Personal Information">
<SimpleGrid cols={3} spacing="md">
<InfoField label="Seafarer ID" value={profile.seafarerId} />
<InfoField label="First Name" value={profile.firstName} />
<InfoField label="Last Name" value={profile.lastName} />
<InfoField label="Gender" value={profile.gender} />
<InfoField label="Date of Birth" value={profile.dob} />
<InfoField label="Nationality" value={profile.nationality} />
<InfoField label="National ID" value={profile.nationalId} />
<InfoField label="Passport No." value={profile.passportNo} />
</SimpleGrid>
</SectionCard>
<SectionCard title="Contact & Status">
<SimpleGrid cols={3} spacing="md" mb="md">
<InfoField label="Mobile" value={profile.mobile} />
<InfoField label="Email" value={profile.email} />
<InfoField label="Region" value={profile.region} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Reg. Status</Text>
<Chip value={profile.status} />
</div>
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Medical Status</Text>
<Chip value={profile.medicalStatus} />
</div>
<InfoField label="Book Number" value={profile.bookNumber} />
<div>
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Book Status</Text>
<Chip value={profile.bookStatus} />
</div>
</SimpleGrid>
<Divider mb="md" />
<Group gap="xs">
{profile.status !== 'Active' && (
<Button size="xs" color="teal" leftSection={<IconCheck size={13} />} onClick={() => onStatusChange('Active')}>
Approve
</Button>
)}
{profile.status !== 'Suspended' && (
<Button size="xs" color="red" variant="light" leftSection={<IconX size={13} />} onClick={() => onStatusChange('Suspended')}>
Suspend
</Button>
)}
<Button size="xs" variant="default" leftSection={<IconFileText size={13} />} onClick={() => notify.info('Documents — coming soon.')}>
Documents
</Button>
</Group>
</SectionCard>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="xs">Permanent Address</Text>
<Text fz="sm" c="dimmed">{profile.permanentAddress || '—'}</Text>
</Paper>
</SimpleGrid>
);
}
// ---------------------------------------------------------------------------
// Tab: Training
// ---------------------------------------------------------------------------
function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Training Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Training</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.course}</Text></Table.Td>
<Table.Td>{r.institution}</Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View training — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No training records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Medical
// ---------------------------------------------------------------------------
function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Medical Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Record</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.examType}</Text></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.result} /></Table.Td>
<Table.Td><Text fz="xs" c="dimmed" style={{ maxWidth: rem(180) }} lineClamp={1}>{r.remarks}</Text></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View medical record — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No medical records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Sea Service
// ---------------------------------------------------------------------------
function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Sea Service Records</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Service</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.vesselName}</Text></Table.Td>
<Table.Td>{r.vesselType}</Table.Td>
<Table.Td>{r.rank}</Table.Td>
<Table.Td>{r.flag}</Table.Td>
<Table.Td>{r.from}</Table.Td>
<Table.Td>{r.to}</Table.Td>
<Table.Td>{r.engagementPort}</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View sea service — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No sea service records found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: Certifications
// ---------------------------------------------------------------------------
function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) {
return (
<Paper withBorder radius="md">
<Group justify="space-between" p="md">
<Text fw={700} fz="sm">Certifications</Text>
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Certification</Button>
</Group>
<Divider />
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{records.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fw={500} fz="sm">{r.name}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
<Table.Td><Badge variant="outline" size="xs" radius="sm">{r.type}</Badge></Table.Td>
<Table.Td>{r.issuedBy}</Table.Td>
<Table.Td>{r.issueDate}</Table.Td>
<Table.Td>{r.expiry}</Table.Td>
<Table.Td><Chip value={r.status} /></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => notify.info('View certificate — coming soon.')}>View</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No certifications found.</Text>}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Tab: History
// ---------------------------------------------------------------------------
function HistoryTab({ entries }: { entries: HistoryEntry[] }) {
return (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Activity History</Text>
<Divider mb="md" />
<Stack gap="sm">
{entries.map((e) => (
<Group key={e.id} gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" size={32} radius="xl" style={{ flexShrink: 0, marginTop: 2 }}>
<IconClock size={15} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs" align="center">
<Text fz="sm" fw={600}>{e.action}</Text>
<Text fz="xs" c="dimmed">by {e.performedBy}</Text>
</Group>
<Text fz="xs" c="dimmed">{e.date}</Text>
{e.notes && <Text fz="xs" mt={2}>{e.notes}</Text>}
</div>
</Group>
))}
{entries.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No history found.</Text>}
</Stack>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Add Record Modal (generic)
// ---------------------------------------------------------------------------
function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Training Record" size="lg">
<Stack gap="sm">
<TextInput label="Course Name" placeholder="e.g. Personal Survival Techniques" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Institution" placeholder="Training institution" />
<TextInput label="Certificate No." placeholder="CERT-0000" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Medical Record" size="lg">
<Stack gap="sm">
<TextInput label="Exam Type" placeholder="e.g. STCW Medical Certificate" required />
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Vessel Name" placeholder="MV Name" required />
<TextInput label="Vessel Type" placeholder="e.g. Bulk Carrier" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
<TextInput label="Flag" placeholder="Country" />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="From" type="date" />
<TextInput label="To" type="date" />
</SimpleGrid>
<TextInput label="Engagement Port" placeholder="Port name" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
return (
<Modal opened={opened} onClose={onClose} title="Add Certification" size="lg">
<Stack gap="sm">
<TextInput label="Certificate Name" placeholder="e.g. STCW Basic Safety Training" required />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Certificate No." placeholder="CERT-0000" />
<Select label="Type" data={['STCW', 'COC', 'COE', 'GMDSS', 'Other']} placeholder="Select type" />
</SimpleGrid>
<TextInput label="Issued By" placeholder="Issuing authority" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" />
<TextInput label="Expiry Date" type="date" />
</SimpleGrid>
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerProfilePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [profile, setProfile] = useState<SeafarerProfile | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<string | null>('overview');
const [trainingModal, trainingModalHandlers] = useDisclosure(false);
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
const [certModal, certModalHandlers] = useDisclosure(false);
useEffect(() => {
if (!id) return;
fetchSeafarerProfile(id)
.then(setProfile)
.catch(() => notify.error('Failed to load seafarer profile.'))
.finally(() => setLoading(false));
}, [id]);
const handleStatusChange = async (newStatus: 'Active' | 'Suspended') => {
if (!profile) return;
try {
await updateSeafarerStatus(profile.id, newStatus);
setProfile((p) => p ? { ...p, status: newStatus } : p);
notify.success(`Status updated to ${newStatus}.`);
} catch {
notify.error('Failed to update status.');
}
};
const initials = profile ? `${profile.firstName[0]}${profile.lastName[0]}` : '??';
return (
<Stack gap="md">
{/* Breadcrumb */}
<Group gap="xs" align="center">
<ActionIcon variant="subtle" size="sm" onClick={() => navigate('/seafarer-registry')}>
<IconArrowLeft size={16} />
</ActionIcon>
<Text fz="sm" c="dimmed" style={{ cursor: 'pointer' }} onClick={() => navigate('/seafarer-registry')}>
Seafarer Registry
</Text>
<Text fz="sm" c="dimmed">/</Text>
<Text fz="sm" fw={500}>
{loading ? <Skeleton width={100} height={14} /> : `${profile?.firstName} ${profile?.lastName}`}
</Text>
</Group>
{/* Profile header card */}
<Paper withBorder radius="md" p="lg">
{loading ? (
<Group gap="md">
<Skeleton circle height={64} />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={20} width={200} />
<Skeleton height={14} width={300} />
<Skeleton height={14} width={400} />
</Stack>
</Group>
) : profile ? (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="lg" wrap="nowrap" align="flex-start">
<Avatar size={64} radius="xl" color="blue" style={{ fontSize: rem(22) }}>
{initials}
</Avatar>
<div>
<Title order={3} lh={1.2}>{profile.firstName} {profile.lastName}</Title>
<Text fz="sm" c="dimmed" mt={2}>
{profile.seafarerId} · Registered {profile.registeredAt}
</Text>
<Group gap="lg" mt={6} wrap="wrap">
<Text fz="sm"><Text span fw={600}>Gender:</Text> {profile.gender}</Text>
<Text fz="sm"><Text span fw={600}>DOB:</Text> {profile.dob}</Text>
<Text fz="sm"><Text span fw={600}>Nationality:</Text> {profile.nationality}</Text>
<Text fz="sm"><Text span fw={600}>Mobile:</Text> {profile.mobile}</Text>
<Text fz="sm"><Text span fw={600}>Email:</Text> {profile.email}</Text>
</Group>
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>
<Button size="xs" variant="default" leftSection={<IconPrinter size={13} />} onClick={() => notify.info('Print — coming soon.')}>
Print Profile
</Button>
</Stack>
</Group>
) : (
<Alert color="red">Profile not found.</Alert>
)}
</Paper>
{/* Tabs */}
{!loading && profile && (
<Tabs value={activeTab} onChange={setActiveTab} variant="outline">
<Tabs.List mb="md">
<Tabs.Tab value="overview" leftSection={<IconLayoutDashboard size={15} />}>Overview</Tabs.Tab>
<Tabs.Tab value="training" leftSection={<IconBook size={15} />}>Training</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={15} />}>Medical</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconShip size={15} />}>Sea Service</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={15} />}>Certifications</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<IconHistory size={15} />}>History</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewTab profile={profile} onStatusChange={handleStatusChange} />
</Tabs.Panel>
<Tabs.Panel value="training">
<TrainingTab records={profile.training} onAdd={trainingModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="medical">
<MedicalTab records={profile.medical} onAdd={medicalModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="sea-service">
<SeaServiceTab records={profile.seaService} onAdd={seaServiceModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="certifications">
<CertificationsTab records={profile.certifications} onAdd={certModalHandlers.open} />
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryTab entries={profile.history} />
</Tabs.Panel>
</Tabs>
)}
{/* Modals */}
<AddTrainingModal opened={trainingModal} onClose={trainingModalHandlers.close} />
<AddMedicalModal opened={medicalModal} onClose={medicalModalHandlers.close} />
<AddSeaServiceModal opened={seaServiceModal} onClose={seaServiceModalHandlers.close} />
<AddCertModal opened={certModal} onClose={certModalHandlers.close} />
</Stack>
);
}

View File

@@ -1,552 +1,259 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
List,
Loader,
Paper,
Select,
SimpleGrid,
Progress,
Stack,
Text,
Textarea,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAddressBook,
IconAlertTriangle,
IconArrowLeft,
IconAnchor,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconClipboardList,
IconInfoCircle,
IconSchool,
IconUser,
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Dummy API
// ---------------------------------------------------------------------------
async function submitSeafarerRegistration(data: unknown): Promise<{ ok: true; referenceId: string }> {
await new Promise((r) => setTimeout(r, 1200));
console.log('Seafarer registration payload:', data);
return { ok: true, referenceId: `SEA-${Date.now()}` };
}
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NATIONALITIES = [
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
];
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
const GENDERS = ['Male', 'Female'];
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
const STEPS = [
{ label: 'Personal Information' },
{ label: 'Contact Details' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
SUSPENDED: 'orange',
INACTIVE: 'gray',
};
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
];
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
// ---------------------------------------------------------------------------
// Section heading
// ---------------------------------------------------------------------------
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
// ---------------------------------------------------------------------------
// Review row
// ---------------------------------------------------------------------------
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// Document upload card
// ---------------------------------------------------------------------------
function DocCard({
slot,
file,
onFile,
}: {
slot: DocSlot;
file: File | null;
onFile: (f: File | null) => void;
}) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: 'dashed',
borderColor: file
? 'var(--mantine-color-teal-5)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box
style={{
width: rem(44),
height: rem(44),
borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">
{slot.label}
{slot.required && <Text span c="red" ml={3}>*</Text>}
</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { onFile(null); resetRef.current?.(); }}
>
Remove
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="default" {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
/**
* The seafarer's registration home (US-SEA-001…007).
*
* Registration itself runs through the config-driven licensing wizard — this
* page is the state machine around it: start a registration, resume or track
* the one in flight, or show the registered identity once approved.
*/
export function SeafarerRegistrationPage() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
// Step 1 — Personal Information
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
const [gender, setGender] = useState<string | null>(null);
const [dob, setDob] = useState<Date | null>(null);
const [placeOfBirth, setPlaceOfBirth] = useState('');
const [nationality, setNationality] = useState<string | null>('Ethiopian');
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
const [nationalIdNumber, setNationalIdNumber] = useState('');
const [passportNumber, setPassportNumber] = useState('');
const [passportExpiry, setPassportExpiry] = useState('');
// The latest registration application, in flight or decided.
const registration = useMemo(() => {
const mine = (applications?.items ?? []).filter(
(app) => app.licenseType?.key === REGISTRATION_TYPE_KEY,
);
return mine.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0];
}, [applications]);
// Step 2 — Contact Details
const [mobile, setMobile] = useState('');
const [email, setEmail] = useState('');
const [locationId, setLocationId] = useState<string | null>(null);
const [permanentAddress, setPermanentAddress] = useState('');
const [currentAddress, setCurrentAddress] = useState('');
const [emergencyName, setEmergencyName] = useState('');
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
const [emergencyPhone, setEmergencyPhone] = useState('');
if (loadingProfile || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
nationalId: null, passport: null, graduation: null, photo: null,
});
const setFile = (key: string) => (f: File | null) =>
setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
if (active === 2) return !!files.nationalId && !!files.photo;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
const result = await submitSeafarerRegistration({
personalInfo: { firstName, middleName, lastName, gender, dob: dob?.toISOString().split('T')[0] ?? '', placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
contactDetails: { mobile, email, locationId, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
});
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
navigate('/applications');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
const stepLabel = STEPS[active]?.label ?? '';
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
const StepIcon = stepIcons[active];
return (
<Stack gap="md">
{/* Page header */}
<div>
<Title order={3}>New Seafarer Registration</Title>
<Text fz="sm" c="dimmed">Register a new seafarer profile Step {active + 1} of {STEPS.length}</Text>
</div>
{/* Step indicator */}
<StepIndicator active={active} completed={completed} />
{/* Card */}
<Paper withBorder radius="lg" p="xl">
{/* Card header */}
<Group justify="space-between" mb="lg">
<Group gap="xs">
<StepIcon size={20} stroke={1.6} />
<Text fw={700} fz="lg">{stepLabel}</Text>
// ------------------------------------------------------------- registered
if (profile?.seafarerNumber) {
const status = profile.seafarerStatus ?? 'ACTIVE';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<Group>
<IconCircleCheck size={32} color="var(--mantine-color-green-6)" />
<div>
<Text fw={700} size="lg">
Registered Seafarer
</Text>
<Text c="dimmed" size="sm">
Your official seafarer profile with the Ethiopian Maritime
Authority.
</Text>
</div>
</Group>
<Badge color={SEAFARER_STATUS_COLORS[status] ?? 'gray'} size="lg">
{status}
</Badge>
</Group>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: Personal Information ───────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<SectionHead title="Identity Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<BilingualInput label="First Name" required value={firstName} onChange={setFirstName} />
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
</SimpleGrid>
<SectionHead title="Identity Documents" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
A unique Seafarer ID will be automatically generated upon approval of this registration.
<Group gap="xl" mt="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Seafarer Number
</Text>
<Text fw={700} ff="monospace" size="lg">
{profile.seafarerNumber}
</Text>
</div>
{profile.seafarerDepartment && (
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
</Text>
<Text fw={600}>
{DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment}
</Text>
</div>
)}
</Group>
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Your profile is suspended: {profile.seafarerStatusReason}
</Alert>
</Stack>
)}
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<SectionHead title="Contact Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Location" />
<LocationPicker
value={locationId ?? undefined}
onChange={setLocationId}
required
/>
<SectionHead title="Address" />
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
<Textarea
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
placeholder="Full current address"
autosize
minRows={2}
value={currentAddress}
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
/>
<SectionHead title="Emergency Contact" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard
key={slot.key}
slot={slot}
file={files[slot.key]}
onFile={setFile(slot.key)}
/>
))}
</SimpleGrid>
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap={6} align="center">
{files[slot.key] ? (
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'} fw={files[slot.key] ? 600 : 400}>
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
</Text>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Personal Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="First Name" value={`${firstName.en}${firstName.am ? ` / ${firstName.am}` : ''}`} />
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
<ReviewRow label="National ID No." value={nationalIdNumber} />
<ReviewRow label="Passport No." value={passportNumber} />
<ReviewRow label="Passport Expiry" value={passportExpiry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Contact Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>
{emergencyName && (
<>
<Divider mt="md" mb="sm" />
<Text fw={600} fz="sm" mb="sm">Emergency Contact</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Name" value={emergencyName} />
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
<ReviewRow label="Phone" value={emergencyPhone} />
</SimpleGrid>
</>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs" align="center">
{files[slot.key] ? (
<IconCircleCheck size={18} color="var(--mantine-color-teal-6)" />
) : (
<Box style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)', flexShrink: 0 }} />
)}
<div>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label}
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
</Text>
{files[slot.key] && (
<Text fz="xs" c="dimmed" truncate maw={160}>{files[slot.key]!.name}</Text>
)}
</div>
</Group>
))}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* Navigation buttons */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/applications')}>
Cancel
)}
</Stack>
</Card>
<Paper withBorder radius="md" p="lg">
<Group justify="space-between">
<div>
<Text fw={600}>Sea service &amp; medical records</Text>
<Text size="sm" c="dimmed">
Keep your sea-service history and medical certificates up to
date certificate and seaman-book applications draw on them.
</Text>
</div>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/seafarer/records')}
>
My records
</Button>
<Group gap="sm">
{active > 0 && (
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
onClick={next}
disabled={!canNext()}
>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={handleSubmit}
loading={submitting}
>
Submit Registration
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}
// -------------------------------------------------------------- in flight
if (registration && !TERMINAL_STATUSES.includes(registration.status)) {
const isDraft = registration.status === 'DRAFT';
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<div>
<Text fw={700}>{registration.applicationNumber}</Text>
<Text size="sm" c="dimmed">
Submitted registrations are reviewed by an EMA registration
officer; you will be notified of every decision.
</Text>
</div>
<Badge color={STATUS_COLORS[registration.status]} size="lg">
{STATUS_LABELS[registration.status]}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[registration.status]} />
{needsAction && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
The registration officer asked for corrections. Open the
application to see exactly what needs fixing.
</Alert>
)}
<Group>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() =>
navigate(
isDraft || needsAction
? `/licensing/${REGISTRATION_TYPE_KEY}/apply`
: `/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`,
)
}
>
{isDraft
? 'Continue registration'
: needsAction
? 'Fix and resubmit'
: 'View application'}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
// ------------------------------------------------------- not yet started
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
{registration?.status === 'REJECTED' && (
<Alert color="red" title="Previous registration rejected">
{registration.rejectionReason ??
'Your previous registration was rejected. You may register again.'}
</Alert>
)}
<Card withBorder radius="md" p="xl">
<Stack>
<Group>
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
<div>
<Text fw={700} size="lg">
Register as a seafarer
</Text>
<Text c="dimmed" size="sm">
Approval creates your official seafarer profile with a unique
seafarer number the identity every maritime service builds on.
</Text>
</div>
</Group>
<Text fw={600} size="sm" mt="sm">
You will need:
</Text>
<List
size="sm"
spacing={4}
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
>
<List.Item>A passport-size photograph</List.Item>
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
<List.Item>Your educational certificate</List.Item>
<List.Item>
A medical fitness certificate and passport, if you already hold
them
</List.Item>
</List>
<Group mt="md">
<Button
size="md"
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,379 +0,0 @@
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Loader,
Menu,
Paper,
Select,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconDotsVertical,
IconEdit,
IconEye,
IconFileExport,
IconSearch,
IconUserCheck,
IconUsers,
IconUserX,
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Seafarer {
id: string;
seafarerId: string;
firstName: string;
lastName: string;
email: string;
gender: 'Male' | 'Female';
nationality: string;
mobile: string;
region: string;
registeredAt: string;
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
status: 'Active' | 'Pending' | 'Suspended';
}
// ---------------------------------------------------------------------------
// Dummy API — replace with real fetch later
// ---------------------------------------------------------------------------
async function fetchSeafarers(): Promise<Seafarer[]> {
await new Promise((r) => setTimeout(r, 900));
return [
{
id: '1',
seafarerId: 'SF-2024-0001',
firstName: 'Abebe',
lastName: 'Girma',
email: 'abebe.g@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 911 234 567',
region: 'Addis Ababa',
registeredAt: '2024-01-10',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
{
id: '2',
seafarerId: 'SF-2024-0002',
firstName: 'Sara',
lastName: 'Tadesse',
email: 'sara.t@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 922 345 678',
region: 'Dire Dawa',
registeredAt: '2024-02-14',
medicalStatus: 'Pending',
bookStatus: 'Pending',
status: 'Pending',
},
{
id: '3',
seafarerId: 'SF-2024-0003',
firstName: 'Dawit',
lastName: 'Bekele',
email: 'dawit.b@email.com',
gender: 'Male',
nationality: 'Ethiopian',
mobile: '+251 933 456 789',
region: 'Oromia',
registeredAt: '2024-03-05',
medicalStatus: 'Fit',
bookStatus: 'Expired',
status: 'Suspended',
},
{
id: '4',
seafarerId: 'SF-2024-0004',
firstName: 'Hana',
lastName: 'Mulugeta',
email: 'hana.m@email.com',
gender: 'Female',
nationality: 'Ethiopian',
mobile: '+251 944 567 890',
region: 'Amhara',
registeredAt: '2024-04-20',
medicalStatus: 'Fit',
bookStatus: 'Active',
status: 'Active',
},
];
}
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
icon: Icon,
color,
loading,
}: {
label: string;
value: number;
icon: typeof IconUsers;
color: string;
loading: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
{loading ? (
<Skeleton height={28} width={40} mb={6} />
) : (
<Title order={2} lh={1}>{value}</Title>
)}
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
</div>
<ThemeIcon variant="light" color={color} size={46} radius="md">
<Icon size={22} stroke={1.6} />
</ThemeIcon>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
const navigate = useNavigate();
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
useEffect(() => {
fetchSeafarers()
.then(setSeafarers)
.catch(() => notify.error('Failed to load seafarers.'))
.finally(() => setLoading(false));
}, []);
const stats = {
total: seafarers.length,
active: seafarers.filter((s) => s.status === 'Active').length,
pending: seafarers.filter((s) => s.status === 'Pending').length,
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
};
const filtered = seafarers.filter((s) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
s.seafarerId.toLowerCase().includes(q) ||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
s.mobile.includes(q) ||
s.email.toLowerCase().includes(q);
const matchStatus = !statusFilter || s.status === statusFilter;
return matchSearch && matchStatus;
});
const rows = filtered.map((s) => (
<Table.Tr key={s.id}>
<Table.Td>
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
{s.seafarerId}
</Text>
</Table.Td>
<Table.Td>
<div>
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
<Text fz="xs" c="dimmed">{s.email}</Text>
</div>
</Table.Td>
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDotsVertical size={15} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
View
</Menu.Item>
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
Edit
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
Suspend
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
</div>
<Button
leftSection={<IconAnchor size={16} />}
onClick={() => navigate('/seafarer-registration')}
>
+ New Registration
</Button>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
</SimpleGrid>
{/* Table card */}
<Paper withBorder radius="md">
{/* Toolbar */}
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Seafarer List</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, ID or mobile…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ minWidth: rem(260) }}
size="sm"
rightSection={
search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
<IconX size={13} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="All Status"
data={['Active', 'Pending', 'Suspended']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
<ActionIcon
variant="default"
size={34}
title="Export"
onClick={() => notify.info('Export — coming soon.')}
>
<IconFileExport size={16} />
</ActionIcon>
</Group>
</Group>
{/* Table */}
{loading ? (
<Stack gap="xs" p="md">
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
</Stack>
) : filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconUsers size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No seafarers found</Text>
{(search || statusFilter) && (
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
Clear filters
</Button>
)}
</Box>
) : (
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
{h}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
{/* Footer */}
{!loading && filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="dimmed">Data loaded</Text>
</Group>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
Stack,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconCircleCheck,
IconCircleX,
IconSearch,
IconShieldCheck,
} from '@tabler/icons-react';
import { useVerifyCertificateQuery } from '@ema-platform/api';
/**
* Public certificate verification (US-PORTAL-008) — the page every printed
* QR code points at. Deliberately public and chrome-free: a bank clerk or
* port official scanning a certificate has no portal account.
*/
export function VerifyCertificatePage() {
const { code } = useParams();
const navigate = useNavigate();
const [manualCode, setManualCode] = useState('');
const { data, isLoading, isError } = useVerifyCertificateQuery(code ?? '', {
skip: !code,
});
const rows: [string, string | null | undefined][] = data?.valid
? [
['Certificate number', data.certificateNumber],
['Licence / registration', data.licenseType],
['Holder', data.companyName],
['Issued', data.issueDate],
['Expires', data.expiryDate],
]
: [];
return (
<Container size="xs" py="xl">
<Stack align="center" gap="lg">
<Group gap="xs">
<IconShieldCheck size={28} color="var(--mantine-color-blue-6)" />
<Title order={3}>EMA Certificate Verification</Title>
</Group>
{!code && (
<Card withBorder radius="md" p="lg" w="100%">
<Stack>
<Text size="sm" c="dimmed">
Scan the QR code on a certificate, or enter its verification
code below.
</Text>
<Group>
<TextInput
flex={1}
placeholder="Verification code"
value={manualCode}
onChange={(e) => setManualCode(e.currentTarget.value)}
/>
<Button
leftSection={<IconSearch size={16} />}
disabled={!manualCode.trim()}
onClick={() => navigate(`/verify/${manualCode.trim()}`)}
>
Verify
</Button>
</Group>
</Stack>
</Card>
)}
{code && isLoading && (
<Center py="xl">
<Loader />
</Center>
)}
{code && !isLoading && (isError || !data) && (
<Card withBorder radius="md" p="lg" w="100%">
<Text c="red" ta="center">
Verification is temporarily unavailable. Please try again.
</Text>
</Card>
)}
{code && data && (
<Card withBorder radius="md" p="lg" w="100%">
<Stack>
{data.valid ? (
<Group>
<IconCircleCheck
size={40}
color="var(--mantine-color-green-6)"
/>
<div>
<Text fw={700} size="lg" c="green">
Valid certificate
</Text>
<Text size="sm" c="dimmed">
Issued by the Ethiopian Maritime Authority.
</Text>
</div>
</Group>
) : (
<Group>
<IconCircleX size={40} color="var(--mantine-color-red-6)" />
<div>
<Group gap="xs">
<Text fw={700} size="lg" c="red">
Not valid
</Text>
{data.status && <Badge color="red">{data.status}</Badge>}
</Group>
<Text size="sm" c="dimmed">
{data.reason === 'not_found'
? 'No certificate matches this code.'
: 'This certificate is no longer valid.'}
</Text>
</div>
</Group>
)}
{rows.length > 0 && (
<Table variant="vertical" layout="fixed">
<Table.Tbody>
{rows
.filter(([, value]) => value)
.map(([label, value]) => (
<Table.Tr key={label}>
<Table.Th w={170}>{label}</Table.Th>
<Table.Td>{value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => {
setManualCode('');
navigate('/verify');
}}
>
Verify another certificate
</Button>
</Stack>
</Card>
)}
</Stack>
</Container>
);
}

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselOwnerDashboardPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel owner dashboard"
description="Vessel registration is not connected to the backend yet."
/>
</Container>
);
}
export default VesselOwnerDashboardPage;

View File

@@ -1,128 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Center,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconLock,
IconMail,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
export function VesselOwnerLoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginTrigger] = useApiMutation<{ token: string; user: { id: string; name: string } }>();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
setError('Please enter your email and password.');
return;
}
setError('');
setLoading(true);
try {
await loginTrigger({
url: '/auth/vessel-owner/login',
method: 'POST',
body: { email, password },
}).unwrap();
notify.success('Login successful. Welcome!');
navigate('/vessel-owner/dashboard');
} catch {
setError('Invalid email or password. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={440} px="md">
{/* Brand */}
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Vessel Owner Portal</Title>
<Text fz="sm" c="dimmed" ta="center">
Ethiopian Maritime Affairs Authority
</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Sign In</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{error}
</Alert>
)}
<Stack gap="md">
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
leftSection={<IconLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<Anchor fz="sm" ta="right" onClick={() => navigate('/vessel-owner/forgot-password')}>
Forgot password?
</Anchor>
<Button fullWidth size="md" loading={loading} onClick={handleLogin} leftSection={<IconAnchor size={16} />}>
Sign In
</Button>
</Stack>
<Divider my="md" label="Don't have an account?" labelPosition="center" />
<Button
fullWidth
variant="light"
onClick={() => navigate('/vessel-owner/register')}
>
Create Vessel Owner Account
</Button>
</Paper>
<Text fz="xs" c="dimmed" ta="center">
This portal is exclusively for vessel owners. For seafarer services,{' '}
<Anchor fz="xs" onClick={() => navigate('/login')}>sign in here</Anchor>.
</Text>
</Stack>
</Box>
);
}

View File

@@ -1,199 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const OWNER_TYPES = [
'Individual (Private Owner)',
'Private Company / PLC',
'State Enterprise',
'NGO / Non-Profit',
'Government Agency',
];
export function VesselOwnerRegisterPage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [ownerType, setOwnerType] = useState<string | null>(null);
const [nationalIdOrTin, setNationalIdOrTin] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
setError('Please fill in all required fields. Passwords must match.');
return;
}
setError('');
setLoading(true);
try {
await registerTrigger({
url: '/auth/vessel-owner/register',
method: 'POST',
body: { fullName, email, phone, ownerType, nationalIdOrTin, password },
}).unwrap();
setSuccess(true);
notify.success('Account created! You can now sign in.');
} catch {
setError('Registration failed. This email may already be registered.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Paper withBorder radius="lg" p="xl" w="100%" maw={440} shadow="sm" mx="md">
<Stack align="center" gap="md">
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCheck size={30} />
</ThemeIcon>
<Title order={3} ta="center">Account Created!</Title>
<Text fz="sm" c="dimmed" ta="center">
Your vessel owner account has been created. You can now sign in and submit vessel registration applications.
</Text>
<Button fullWidth onClick={() => navigate('/vessel-owner/login')}>
Sign In Now
</Button>
</Stack>
</Paper>
</Box>
);
}
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={540} px="md">
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Create Vessel Owner Account</Title>
<Text fz="sm" c="dimmed" ta="center">Ethiopian Maritime Affairs Authority</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Owner Registration</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">{error}</Alert>
)}
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Full Name / Company Name"
placeholder="e.g. Abebe Girma"
leftSection={<IconUser size={16} />}
required
value={fullName}
onChange={(e) => setFullName(e.currentTarget.value)}
/>
<Select
label="Owner Type"
placeholder="Select type"
required
data={OWNER_TYPES}
value={ownerType}
onChange={setOwnerType}
/>
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="ET-0000000 or TIN"
required
value={nationalIdOrTin}
onChange={(e) => setNationalIdOrTin(e.currentTarget.value)}
/>
</SimpleGrid>
<Divider label="Set Password" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="Min. 8 characters"
leftSection={<IconLock size={16} />}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<PasswordInput
label="Confirm Password"
placeholder="Repeat password"
leftSection={<IconLock size={16} />}
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
error={confirmPassword && password !== confirmPassword ? 'Passwords do not match' : undefined}
/>
</SimpleGrid>
<Button fullWidth size="md" loading={loading} disabled={!canSubmit} onClick={handleRegister}>
Create Account
</Button>
</Stack>
<Divider my="md" label="Already have an account?" labelPosition="center" />
<Button fullWidth variant="light" onClick={() => navigate('/vessel-owner/login')}>
Sign In
</Button>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -1,174 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconInfoCircle,
IconShip,
IconTransferIn,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
interface VesselRegistrationSummary {
id: string;
vesselName: string;
category: string;
status: VesselRegStatus;
submittedDate: string;
renewalStatus: 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
const RENEWAL_COLOR: Record<string, string> = {
Valid: 'teal',
'Due Soon': 'orange',
Overdue: 'red',
'Not Applicable': 'gray',
};
export function VesselRegistrationDashboardPage() {
const navigate = useNavigate();
const [vessels, setVessels] = useState<VesselRegistrationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [fetchTrigger] = useApiMutation<VesselRegistrationSummary[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setVessels(data))
.catch(() => setVessels([]))
.finally(() => setLoading(false));
}, [fetchTrigger]);
const stats = {
total: vessels.length,
pending: vessels.filter((v) => v.status === 'Pending' || v.status === 'Under Review' || v.status === 'Correction Required').length,
approved: vessels.filter((v) => v.status === 'Approved').length,
renewalsDue: vessels.filter((v) => v.renewalStatus === 'Due Soon' || v.renewalStatus === 'Overdue').length,
};
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>My Vessel Registration Dashboard</Title>
<Text fz="sm" c="dimmed">Track your vessel registrations and ownership transfers</Text>
</div>
</Group>
<Group gap="sm">
<Button variant="light" leftSection={<IconTransferIn size={16} />} onClick={() => navigate('/vessel-registration/transfer')}>
Ownership Transfer
</Button>
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
Register New Vessel
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{loading ? (
<>
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
</>
) : (
<>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Total Vessels</Text>
<Text fz="xl" fw={700} c="blue.6" mt={4}>{stats.total}</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>In Progress</Text>
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.pending}</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Approved</Text>
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.approved}</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Renewals Due</Text>
<Text fz="xl" fw={700} c="orange.6" mt={4}>{stats.renewalsDue}</Text>
</Card>
</>
)}
</SimpleGrid>
{!loading && vessels.length === 0 ? (
<Paper withBorder radius="lg" p="xl">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
<IconAnchor size={30} />
</ThemeIcon>
<Title order={4} ta="center">No Vessels Registered</Title>
<Text fz="sm" c="dimmed" ta="center" maw={400}>
You haven't registered any vessels yet. Click "Register New Vessel" to begin.
</Text>
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{vessels.map((v) => (
<Card key={v.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconShip size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{v.vesselName}</Text>
<Text fz="xs" c="dimmed">{v.id}</Text>
</div>
</Group>
<Badge size="sm" variant="light" color={STATUS_COLOR[v.status] ?? 'gray'}>{v.status}</Badge>
</Group>
<Group justify="space-between" mt="sm">
<Text fz="xs" c="dimmed">Renewal</Text>
<Badge size="xs" variant="light" color={RENEWAL_COLOR[v.renewalStatus]}>{v.renewalStatus}</Badge>
</Group>
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
View Details
</Button>
</Card>
))}
</SimpleGrid>
)}
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
</Alert>
</Stack>
);
}

View File

@@ -1,678 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'Vessel Category' },
{ label: 'Vessel Details' },
{ label: 'Technical & Ownership' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
const VESSEL_TYPES_INLAND = [
'Passenger Ferry', 'Cargo Barge', 'Fishing Vessel', 'Tug Boat',
'Dredger', 'Patrol/Inspection Boat', 'Pleasure Craft', 'Water Taxi',
];
const VESSEL_TYPES_SEAGOING = [
'Container Ship', 'Bulk Carrier', 'Tanker', 'General Cargo',
'Ro-Ro Vessel', 'Passenger/Cruise Ship', 'Fishing Vessel', 'Trawler',
'Yacht/Pleasure Craft', 'Chemical Tanker', 'LPG Carrier', 'Multi-Purpose Vessel',
];
const ENGINE_TYPES = [
'Diesel Engine', 'Dual-Fuel Engine', 'Electric Motor', 'Hybrid Diesel-Electric',
'Steam Turbine', 'Gas Turbine', 'Outboard Motor', 'Inboard Petrol Engine',
];
const HULL_MATERIALS = [
'Steel', 'Aluminum', 'Fiberglass/GRP', 'Wood', 'Ferro-Cement',
];
const PASSENGER_VESSEL_TYPES = new Set([
'Passenger Ferry', 'Passenger/Cruise Ship', 'Water Taxi', 'Yacht/Pleasure Craft', 'Pleasure Craft',
]);
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
// ---------------------------------------------------------------------------
// Step indicator (matches SeafarerRegistrationPage pattern)
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function DocCard({
slot,
file,
onFile,
}: {
slot: DocSlot;
file: File | null;
onFile: (f: File | null) => void;
}) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: 'dashed',
borderColor: file
? 'var(--mantine-color-teal-5)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box
style={{
width: rem(44),
height: rem(44),
borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">
{slot.label}
{slot.required && <Text span c="red" ml={3}>*</Text>}
</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { onFile(null); resetRef.current?.(); }}
>
Remove
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="default" {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Category
const [category, setCategory] = useState<string | null>(null);
// Step 1 — Vessel Details
const [vesselName, setVesselName] = useState('');
const [vesselType, setVesselType] = useState<string | null>(null);
const [capacityValue, setCapacityValue] = useState<string | number>('');
const [vesselLengthM, setVesselLengthM] = useState<string | number>('');
const [flagState, setFlagState] = useState('Ethiopia');
const [portOfRegistry, setPortOfRegistry] = useState('');
// Step 2 — Technical & Ownership
const [imoOrHullNumber, setImoOrHullNumber] = useState('');
const [manufacturerShipyard, setManufacturerShipyard] = useState('');
const [yearBuilt, setYearBuilt] = useState<string | number>('');
const [engineType, setEngineType] = useState<string | null>(null);
const [enginePowerKw, setEnginePowerKw] = useState<string | number>('');
const [numberOfEngines, setNumberOfEngines] = useState<string | number>('');
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
const [ownerName, setOwnerName] = useState('');
const [ownerNationalIdOrTin, setOwnerNationalIdOrTin] = useState('');
const [ownerPhone, setOwnerPhone] = useState('');
const [ownerAddress, setOwnerAddress] = useState('');
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
vesselPhotos: null, proofOfOwnership: null, shipParticulars: null, insuranceCertificate: null,
});
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
// Reset vessel type when category changes
useEffect(() => { setVesselType(null); }, [category]);
// Derived
const vesselTypeOptions = category === 'Inland Waterway Vessel' ? VESSEL_TYPES_INLAND : VESSEL_TYPES_SEAGOING;
const capacityLabel = PASSENGER_VESSEL_TYPES.has(vesselType ?? '') ? 'Passenger Capacity' : 'Gross Tonnage (GT)';
const idLabel = category === 'Sea-going Vessel (International)' ? 'IMO Number' : 'Hull/Registration Number';
// Inland: only vessel photos required
// Sea-going: vessel photos + proof of ownership + ship particulars + insurance
const docSlots: DocSlot[] = category === 'Inland Waterway Vessel'
? [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
]
: [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', description: 'Legal document proving ownership of the vessel', required: true, icon: IconFileDescription },
{ key: 'shipParticulars', label: 'Ship Particulars', description: 'Detailed technical specifications issued by the shipyard', required: true, icon: IconId },
{ key: 'insuranceCertificate', label: 'Insurance Certificate', description: 'Valid hull and machinery insurance policy', required: true, icon: IconShieldCheck },
];
const canNext = () => {
if (active === 0) return !!category;
if (active === 1) return (
!!vesselName.trim() && !!vesselType && !!capacityValue && !!vesselLengthM &&
!!flagState.trim() && !!portOfRegistry.trim()
);
if (active === 2) return (
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
);
if (active === 3) return category === 'Inland Waterway Vessel'
? !!files.vesselPhotos
: !!files.vesselPhotos && !!files.proofOfOwnership && !!files.shipParticulars && !!files.insuranceCertificate;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-registrations',
method: 'POST',
body: {
category, vesselName, vesselType, capacityLabel, capacityValue, vesselLengthM,
flagState, portOfRegistry, imoOrHullNumber, manufacturerShipyard, yearBuilt,
engineType, enginePowerKw, numberOfEngines, hullMaterial,
ownerName, ownerNationalIdOrTin, ownerPhone, ownerAddress,
},
}).unwrap();
notify.success('Vessel registration submitted successfully!');
navigate('/vessel-registration');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Vessel Registration Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<StepIndicator active={active} completed={completed} />
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 0: Vessel Category ──────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Text fz="sm" c="dimmed">Select the primary use category of the vessel to be registered.</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{[
{
value: 'Inland Waterway Vessel',
icon: IconWaveSine,
title: 'Inland Waterway Vessel',
desc: 'Vessels operating on lakes, rivers, and inland waterways within Ethiopia (e.g. Lake Tana, Hawassa, Blue Nile)',
},
{
value: 'Sea-going Vessel (International)',
icon: IconShip,
title: 'Sea-going Vessel (International)',
desc: 'Vessels operating in international waters, Red Sea, Gulf of Aden, and ocean routes',
},
].map((opt) => {
const Icon = opt.icon;
const selected = category === opt.value;
return (
<Card
key={opt.value}
withBorder
radius="md"
p="lg"
style={{
cursor: 'pointer',
borderColor: selected ? 'var(--mantine-color-blue-5)' : 'var(--mantine-color-default-border)',
borderWidth: selected ? 2 : 1,
background: selected ? 'var(--mantine-color-blue-light)' : undefined,
transition: 'all 0.15s ease',
}}
onClick={() => { setCategory(opt.value); next(); }}
>
<ThemeIcon size={48} radius="md" color="blue" variant={selected ? 'filled' : 'light'} mb="sm">
<Icon size={26} />
</ThemeIcon>
<Text fw={700} fz="md" mb={4}>{opt.title}</Text>
<Text fz="sm" c="dimmed">{opt.desc}</Text>
</Card>
);
})}
</SimpleGrid>
</Stack>
)}
{/* ── Step 1: Vessel Details ───────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Category: <strong>{category}</strong>
</Alert>
<SectionHead title="Vessel Identification" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Vessel Name"
placeholder="e.g. Lake Tana Star"
required
value={vesselName}
onChange={(e) => setVesselName(e.currentTarget.value)}
/>
<Select
label="Vessel Type"
placeholder="Select vessel type"
required
data={vesselTypeOptions}
value={vesselType}
onChange={setVesselType}
/>
<NumberInput
label={capacityLabel}
placeholder="Enter value"
required
min={1}
value={capacityValue}
onChange={setCapacityValue}
/>
<NumberInput
label="Vessel Length (meters)"
placeholder="e.g. 32"
required
min={1}
value={vesselLengthM}
onChange={setVesselLengthM}
/>
<TextInput
label="Flag State"
required
value={flagState}
onChange={(e) => setFlagState(e.currentTarget.value)}
/>
<TextInput
label="Registration Area"
placeholder="e.g. Bahir Dar"
required
value={portOfRegistry}
onChange={(e) => setPortOfRegistry(e.currentTarget.value)}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 2: Technical & Ownership ───────────────────────── */}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Technical Specifications" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label={idLabel}
placeholder={category === 'Sea-going Vessel (International)' ? 'IMO0000000' : 'ETH-INL-0000'}
required
value={imoOrHullNumber}
onChange={(e) => setImoOrHullNumber(e.currentTarget.value)}
/>
<TextInput
label="Manufacturer / Shipyard Name"
placeholder="e.g. Hyundai Heavy Industries"
required
value={manufacturerShipyard}
onChange={(e) => setManufacturerShipyard(e.currentTarget.value)}
/>
<NumberInput
label="Year Built"
placeholder="e.g. 2019"
required
min={1900}
max={new Date().getFullYear()}
value={yearBuilt}
onChange={setYearBuilt}
/>
<Select
label="Engine Type"
placeholder="Select engine type"
required
data={ENGINE_TYPES}
value={engineType}
onChange={setEngineType}
/>
<NumberInput
label="Engine Power (kW)"
placeholder="e.g. 450"
required
min={1}
value={enginePowerKw}
onChange={setEnginePowerKw}
/>
<NumberInput
label="Number of Engines"
placeholder="e.g. 2"
required
min={1}
max={12}
value={numberOfEngines}
onChange={setNumberOfEngines}
/>
<Select
label="Hull Material"
placeholder="Select material"
required
data={HULL_MATERIALS}
value={hullMaterial}
onChange={setHullMaterial}
/>
</SimpleGrid>
<SectionHead title="Owner Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Owner Name / Company"
placeholder="e.g. Abebe Girma"
required
value={ownerName}
onChange={(e) => setOwnerName(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="e.g. ET-9812345"
required
value={ownerNationalIdOrTin}
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
/>
<TextInput
label="Owner Phone"
placeholder="+251 9XX XXX XXX"
required
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
/>
<TextInput
label="Owner Address"
placeholder="City, Region"
value={ownerAddress}
onChange={(e) => setOwnerAddress(e.currentTarget.value)}
style={{ gridColumn: 'span 2' }}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ─────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{docSlots.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{/* ── Step 4: Review & Submit ──────────────────────────────── */}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. You will be notified by the authority on application status.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Category" value={category ?? ''} />
<ReviewRow label="Vessel Name" value={vesselName} />
<ReviewRow label="Vessel Type" value={vesselType ?? ''} />
<ReviewRow label={capacityLabel} value={String(capacityValue)} />
<ReviewRow label="Vessel Length (m)" value={String(vesselLengthM)} />
<ReviewRow label="Flag State" value={flagState} />
<ReviewRow label="Registration Area" value={portOfRegistry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label={idLabel} value={imoOrHullNumber} />
<ReviewRow label="Manufacturer / Shipyard" value={manufacturerShipyard} />
<ReviewRow label="Year Built" value={String(yearBuilt)} />
<ReviewRow label="Engine Type" value={engineType ?? ''} />
<ReviewRow label="Engine Power (kW)" value={String(enginePowerKw)} />
<ReviewRow label="Number of Engines" value={String(numberOfEngines)} />
<ReviewRow label="Hull Material" value={hullMaterial ?? ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Owner Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Owner Name" value={ownerName} />
<ReviewRow label="National ID / TIN" value={ownerNationalIdOrTin} />
<ReviewRow label="Phone" value={ownerPhone} />
<ReviewRow label="Address" value={ownerAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{docSlots.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={active === 0 ? () => navigate('/vessel-registration') : prev}
>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
disabled={!canNext()}
onClick={next}
>
Next
</Button>
) : (
<Button
color="teal"
leftSection={<IconAnchor size={16} />}
loading={submitting}
onClick={handleSubmit}
>
Submit Registration
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,341 +1,428 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Progress,
Stack,
Table,
Text,
ThemeIcon,
TextInput,
Textarea,
Title,
rem,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconCheck,
IconCircleCheck,
IconAlertCircle,
IconFileDescription,
IconShieldCheck,
IconArrowRight,
IconCertificate,
IconDownload,
IconInfoCircle,
IconClockHour4,
IconTransferIn,
IconPlus,
IconRefresh,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
extractErrorMessage,
useCreateApplicationMutation,
useCreateVesselIncidentMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetMyVesselsQuery,
} from '@ema-platform/api';
import type { Vessel } from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
interface VesselRegistration {
id: string;
vesselName: string;
category: VesselCategory;
vesselType: string;
flagState: string;
portOfRegistry: string;
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
capacityValue: number;
vesselLengthM: number;
imoOrHullNumber: string;
ownerName: string;
status: VesselRegStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
renewalStatus: RenewalStatus;
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
// Inland vessel certificates (1)
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
// Sea-going vessel certificates (4)
const SEAGOING_CERTIFICATES = [
{ label: 'Certificate of Nationality', description: 'Certifies the vessel\'s nationality and right to fly the Ethiopian flag' },
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
];
/** US-VES-016: the owner reports an accident or incident on their vessel. */
function IncidentModal({
vessel,
onClose,
}: {
vessel: Vessel | null;
onClose: () => void;
}) {
const [occurredAt, setOccurredAt] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const [createIncident, { isLoading }] = useCreateVesselIncidentMutation();
const submit = async () => {
if (!vessel) return;
try {
await createIncident({
vesselId: vessel.id,
body: {
occurredAt,
description,
...(location ? { location } : {}),
},
}).unwrap();
notify.success('Incident recorded');
onClose();
setOccurredAt('');
setLocation('');
setDescription('');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not record the incident'));
}
};
// ---------------------------------------------------------------------------
// Requirements list
// ---------------------------------------------------------------------------
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light">
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
<Modal
opened={Boolean(vessel)}
onClose={onClose}
title={`Report incident — ${vessel?.name ?? ''}`}
centered
>
<Stack>
<TextInput
type="date"
label="Date of occurrence"
required
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
<TextInput
label="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<Textarea
label="What happened"
required
minRows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={isLoading}
disabled={!occurredAt || description.trim().length < 10}
onClick={submit}
>
Record incident
</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Certificate card (shown after approval)
// ---------------------------------------------------------------------------
function CertificateCard({ label, description }: { label: string; description: string }) {
return (
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={600} fz="sm">{label}</Text>
<Text fz="xs" c="dimmed">{description}</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* The vessel owner's home (US-VES-001…009, 016): registered vessels with
* their certificates and renewals, registrations still in flight, and the
* entry point into the config-driven registration wizard.
*/
export function VesselRegistrationPage() {
const navigate = useNavigate();
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [createApplication] = useCreateApplicationMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [incidentFor, setIncidentFor] = useState<Vessel | null>(null);
// `/vessel-registrations/my` resolves the owner from the token, so it never
// needed a profile id. Gating on one meant anyone who signed up after the
// setup wizard was removed — and so had nothing in local storage — silently
// never loaded their registration.
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setRegistration(data))
.catch(() => {/* no registration yet */});
}, [fetchTrigger]);
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
const certs = registration?.category === 'Sea-going Vessel (International)'
? SEAGOING_CERTIFICATES
: INLAND_CERTIFICATES;
const licenseById = new Map(
(licenses?.items ?? []).map((license) => [license.id, license]),
);
async function downloadCertificate(vessel: Vessel) {
try {
const result = await getCertificateUrl(vessel.licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not fetch the certificate'),
);
}
}
/** A renewal is an ordinary application of kind RENEWAL (US-VES-009). */
async function renew(vessel: Vessel) {
try {
const application = await createApplication({
licenseType: REGISTRATION_TYPE_KEY,
kind: 'RENEWAL',
previousLicenseId: vessel.licenseId,
}).unwrap();
navigate(
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${application.id}`,
);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the renewal'));
}
}
if (loadingVessels || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration</Title>
<Text fz="sm" c="dimmed">Register your vessel with the Ethiopian Maritime Authority</Text>
</div>
<Stack>
<Group justify="space-between">
<Title order={2}>Vessel Registration</Title>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Register a vessel
</Button>
</Group>
{/* ── No registration yet ───────────────────────────────────────── */}
{!registration && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light">
<IconAnchor size={28} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">Register Your Vessel</Text>
<Text fz="sm" c="dimmed">
Obtain official registration for inland waterway or sea-going vessels
</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Proof of Ownership / Bill of Sale" />
<RequirementItem label="Builder's Certificate or Technical Specifications" />
<RequirementItem label="Valid Insurance Certificate (Hull & Machinery)" />
<RequirementItem label="Tax Clearance Certificate" />
<RequirementItem label="Vessel Photos (at least 2 clear images)" />
<RequirementItem label="IMO Certificate of Registry (sea-going re-registration only)" />
</Stack>
<Button
size="md"
leftSection={<IconAnchor size={18} />}
onClick={() => navigate('/vessel-registration/apply')}
>
Start Vessel Registration
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Vessel Registration</Text>
</Group>
<Text fz="sm" c="dimmed">
Registration is valid for <strong>5 years</strong> from the date of approval. After approval,
inland vessels receive an <strong>Inland Vessel Registration Certificate</strong>, while
sea-going vessels receive four certificates: Certificate of Nationality, Certificate of
Ownership, Certificate of Registration, and Minimum Safe Manning Certificate.
</Text>
</Paper>
</>
)}
{/* ── Registration exists ──────────────────────────────────────── */}
{registration && (
<>
{/* Renewal alert */}
{registration.renewalStatus === 'Due Soon' && (
<Alert
icon={<IconAlertCircle size={17} />}
color="orange"
title="Renewal Due Soon"
>
Your vessel registration expires on {registration.expiryDate}. Please initiate renewal to avoid expiry.
<Button size="xs" variant="white" color="orange" mt="xs">
Start Renewal
</Button>
</Alert>
)}
{registration.renewalStatus === 'Overdue' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Registration Expired">
Your vessel registration expired on {registration.expiryDate}. Immediate renewal is required.
</Alert>
)}
{/* Status card */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light">
<IconAnchor size={22} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">{registration.vesselName}</Text>
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[
{ label: 'Category', value: registration.category },
{ label: 'Vessel Type', value: registration.vesselType },
{ label: 'Flag State', value: registration.flagState },
{ label: 'Port of Registry', value: registration.portOfRegistry },
{ label: registration.capacityLabel, value: String(registration.capacityValue) },
{ label: 'Submitted', value: registration.submittedDate },
].map((row) => (
<div key={row.label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{row.label}</Text>
<Text fz="sm" mt={2}>{row.value || '—'}</Text>
</div>
))}
</SimpleGrid>
{registration.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{registration.remarks}</Text>
</>
)}
</Paper>
{/* Timeline / status info */}
{registration.status !== 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Review', done: registration.status !== 'Pending' },
{ label: 'Approved', done: false },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Registrations in progress</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
const vesselName =
(app.formData?.vesselDetails?.vesselName as string) ?? null;
return (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Group gap="xs">
<Text fw={600}>{app.applicationNumber}</Text>
{vesselName && (
<Text c="dimmed" size="sm">
{vesselName}
</Text>
)}
{app.kind === 'RENEWAL' && (
<Badge variant="light">Renewal</Badge>
)}
</Group>
<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/${REGISTRATION_TYPE_KEY}/applications/${app.id}`,
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
</Button>
</Group>
))}
</Stack>
</Paper>
)}
{/* Transfer ownership — only when approved */}
{registration.status === 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600} fz="sm">Transfer Ownership</Text>
<Text fz="xs" c="dimmed">Transfer this vessel to a new owner</Text>
</div>
<Button
leftSection={<IconTransferIn size={15} />}
color="violet"
variant="light"
size="sm"
onClick={() => navigate('/vessel-registration/transfer')}
>
Request Transfer
</Button>
</Group>
</Paper>
)}
{/* Certificates section — shown after approval */}
{registration.status === 'Approved' && (
<div>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">
{registration.category === 'Sea-going Vessel (International)'
? 'Issued Certificates (4)'
: 'Issued Certificate'}
</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your vessel registration has been approved. You may download your certificate(s) below.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{certs.map((cert) => (
<CertificateCard key={cert.label} label={cert.label} description={cert.description} />
))}
</SimpleGrid>
</div>
)}
</>
</Group>
</Card>
);
})}
</Stack>
)}
{/* ------------------------------------------------------- register */}
<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}>
Register an inland-waterway or sea-going vessel. Approval
issues the registration certificate and enters the vessel in
the national register.
</Text>
<Button
mt="xs"
onClick={() =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
}
>
Start registration
</Button>
</Stack>
</Paper>
) : (
<Table.ScrollContainer minWidth={760}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Certificate</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vessels ?? []).map((vessel) => {
const license = licenseById.get(vessel.licenseId);
const renewable = license?.renewable ?? false;
const expiring =
license?.daysUntilExpiry !== undefined &&
license.daysUntilExpiry <= 60;
return (
<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 ?? '—'}
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
{license ? (
<Group gap={6} wrap="nowrap">
<Badge
size="sm"
variant="light"
color={
license.status === 'ACTIVE'
? expiring
? 'yellow'
: 'green'
: 'red'
}
>
{license.status === 'ACTIVE' && expiring
? `Expires in ${license.daysUntilExpiry}d`
: license.status}
</Badge>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Download certificate">
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => downloadCertificate(vessel)}
>
Certificate
</Button>
</Tooltip>
{renewable && vessel.status === 'REGISTERED' && (
<Tooltip label="Renew the registration">
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => renew(vessel)}
>
Renew
</Button>
</Tooltip>
)}
<Tooltip label="Report accident / incident">
<Button
size="compact-xs"
variant="subtle"
color="orange"
leftSection={<IconAlertTriangle size={14} />}
onClick={() => setIncidentFor(vessel)}
>
Incident
</Button>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
{(vessels ?? []).some((v) => v.status === 'SUSPENDED') && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
A suspended vessel may not operate. Contact the Ethiopian Maritime
Authority about reinstatement.
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group gap="xs">
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
<Text size="sm" c="dimmed">
Ownership transfer, amendment and duplicate-certificate services
are coming in a later release.
</Text>
</Group>
</Paper>
<IncidentModal vessel={incidentFor} onClose={() => setIncidentFor(null)} />
</Stack>
);
}
export default VesselRegistrationPage;

View File

@@ -1,23 +1,216 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconFileText,
IconInfoCircle,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
/**
* Placeholder until waivers have a backend.
* Maritime waiver home (US-WAV-001/002/009).
*
* This page previously ran a full application wizard against
* `/logistics-waivers`, an endpoint the API has never exposed: the request
* always failed, and the documents the applicant uploaded were never sent
* anywhere. Saying nothing is available is more honest than collecting
* paperwork into a void.
* The two waiver kinds are seeded licence types, so the application itself is
* the config-driven wizard and this page only has to be the door: which kind
* applies, what is in flight, and the issued letters (US-WAV-007) with their
* verifiable references (US-WAV-010).
*/
export function WaiverPage() {
const navigate = useNavigate();
const { data: applications, isLoading } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const waiverApplications = (applications?.items ?? []).filter((app) =>
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = waiverApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const letters = (licenses?.items ?? []).filter((license) =>
WAIVER_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch the letter'));
}
}
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Waiver applications"
description="Waivers are not connected to the backend yet. You will be able to apply here once EMA enables them."
/>
</Container>
<Stack maw={900} mx="auto">
<div>
<Title order={2}>Maritime Waiver</Title>
<Text size="sm" c="dimmed">
Apply for a waiver when Ethiopian Shipping &amp; Logistics cannot
carry your shipment. Approval issues the bank waiver letter.
</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Card withBorder radius="md" p="lg">
<Text fw={600}>Pre-waiver</Text>
<Text size="sm" c="dimmed" mt={4} mb="md">
The cargo has not yet arrived. Applying before arrival avoids the
post-waiver penalty.
</Text>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
>
Apply for a pre-waiver
</Button>
</Card>
<Card withBorder radius="md" p="lg">
<Text fw={600}>Post-waiver</Text>
<Text size="sm" c="dimmed" mt={4} mb="md">
The cargo has already arrived. Granted once per shipment, and only
against a settled penalty with the receipt attached.
</Text>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
>
Apply for a post-waiver
</Button>
</Card>
</SimpleGrid>
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
Each waiver covers one shipment, identified by its bill of lading. A
second application quoting the same bill of lading is refused while the
first is live.
</Alert>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
{inFlight.map((app) => (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Text size="xs" c="dimmed">
{app.licenseType?.name?.en}
</Text>
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant="light"
onClick={() =>
navigate(
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<Stack gap="xs">
<Title order={4}>Issued waiver letters</Title>
{letters.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No waiver letters issued yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{letters.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{license.licenseType?.name?.en ?? '—'}</Table.Td>
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="light"
leftSection={<IconFileText size={13} />}
onClick={() => download(license.id)}
>
Letter
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
</Stack>
);
}

View File

@@ -36,6 +36,9 @@ export const am: Translations = {
waiver: 'ነፃ ፈቃድ',
dashboard: 'ዳሽቦርድ',
seafarerRegistry: 'የመርከበኞች ምዝገባ',
seafarerRegistration: 'የባህረኛ ምዝገባ',
exams: 'ፈተናዎች',
seaRecords: 'የባህር መዝገቦቼ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
endorsements: 'ማረጋገጫዎች',

View File

@@ -24,6 +24,9 @@ export const en = {
myApplications: 'My Applications',
dashboard: 'Dashboard',
seafarerRegistry: 'Seafarer Registry',
seafarerRegistration: 'Seafarer Registration',
exams: 'Examinations',
seaRecords: 'My Sea Records',
myApplication: 'My Application',
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',

View File

@@ -43,22 +43,24 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
label: 'nav.groupLicensing',
items: [
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, soon: true },
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
],
},
{
label: 'nav.groupSeafarer',
items: [
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, soon: true },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, soon: true },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, soon: true },
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, soon: true },
],
},
@@ -80,9 +82,11 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
'/licensing/applications': { i18nKey: 'nav.myApplications' },
'/waiver': { i18nKey: 'nav.waiver' },
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
'/seafarer/records': { i18nKey: 'nav.seaRecords' },
'/seaman-book': { i18nKey: 'nav.myApplication' },
'/certificates': { i18nKey: 'nav.certificates' },
'/exams': { i18nKey: 'nav.exams' },
'/endorsements': { i18nKey: 'nav.endorsements' },
'/documents': { i18nKey: 'nav.documents' },
'/notifications':{ i18nKey: 'nav.notifications' },

View File

@@ -1,80 +0,0 @@
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconAnchor, IconHome2, IconTransferIn, IconUserCircle } from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import { BrandMark, logout } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
// Only vessel-registration routes are exposed to vessel owners
const NAV_ITEMS: NavItem[] = [
{ to: '/vessel-owner/dashboard', label: 'My Vessels', icon: IconHome2 },
{ to: '/vessel-registration', label: 'Vessel Registration', icon: IconAnchor },
{ to: '/vessel-owner/registration/transfer', label: 'Ownership Transfer', icon: IconTransferIn },
{ to: '/vessel-owner/profile', label: 'My Profile', icon: IconUserCircle },
];
export function VesselOwnerLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const user = useAppSelector((state) => state.auth.user);
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
const displayName = user?.name?.en || user?.username || 'Vessel Owner';
const initials = displayName.split(/\s+/).map((s: string) => s[0]).join('').toUpperCase().slice(0, 2) || 'VO';
const go = (item: NavItem) => {
if (item.soon) { notify.info(`${item.label} — coming soon.`); return; }
if (item.to) { navigate(item.to); closeNav(); }
};
const handleLogout = () => { dispatch(logout()); navigate('/vessel-owner/login'); };
return (
<AppShell
header={{ height: 74 }}
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
padding="lg"
>
<AppShell.Header style={{ background: 'var(--mantine-color-body)', borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleSidebar}
navOpened={navOpened}
breadcrumbs={[{ label: 'Vessel Owner Portal', path: '/vessel-owner/dashboard' }]}
onNavigate={navigate}
onLogout={handleLogout}
userName={displayName}
userInitials={initials}
supportedLanguages={SUPPORTED_LANGUAGES}
/>
</AppShell.Header>
<AppShell.Navbar p={0} style={{ overflow: 'hidden', transition: 'width 200ms ease', background: 'var(--mantine-color-body)', borderRight: '1px solid var(--mantine-color-gray-2)' }}>
<AppSidebar
navItems={NAV_ITEMS}
collapsed={sidebarCollapsed}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName="Vessel Owner Portal"
brandSubtitle="EMAA"
brandLogo={<BrandMark size={32} />}
/>
</AppShell.Navbar>
<AppShell.Main>
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
</AppShell.Main>
</AppShell>
);
}

View File

@@ -5,7 +5,7 @@ import { PortalLayout } from './layouts/PortalLayout';
import { ProtectedRoute } from './components/ProtectedRoute';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage, SetPasswordPage } from '@ema-platform/auth';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
@@ -14,8 +14,9 @@ import { OperationsOnboardingPage } from './features/onboarding/pages/Operations
import { ProfilePage } from './features/profile/pages/ProfilePage';
import { SupportPage } from './features/support/pages/SupportPage';
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage';
import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage';
import { ExamsPage } from './features/exams/pages/ExamsPage';
// Phase 1 pages
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
@@ -30,13 +31,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 { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
import { VesselRegistrationDashboardPage } from './features/vessel-registration-dashboard/pages/VesselRegistrationDashboardPage';
import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage';
import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage';
import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage';
@@ -49,6 +44,17 @@ export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
// Public certificate verification — the target of every printed QR code.
// No auth: a verifier scanning a certificate has no portal account.
{ path: '/verify', element: <VerifyCertificatePage /> },
{ path: '/verify/:code', element: <VerifyCertificatePage /> },
// Completes the forgot-password flow; the reset message links here. The
// IAM package generates `/reset-password` links, `/set-password` is the
// first-time-credential variant — one page serves both.
{ path: '/set-password', element: <SetPasswordPage /> },
{ path: '/reset-password', element: <SetPasswordPage /> },
// Protected auth pages
{
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
@@ -97,8 +103,12 @@ export const router = createBrowserRouter([
// Seafarer
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
{ path: '/seafarer/records', element: <MySeaRecordsPage /> },
{ path: '/exams', element: <ExamsPage /> },
// The public-facing registry was a hardcoded mock and does not belong in
// the applicant portal; officers browse seafarers in the backoffice.
{ path: '/seafarer-registry', element: <Navigate to="/seafarer-registration" replace /> },
{ path: '/seafarer-registry/:id', element: <Navigate to="/seafarer-registration" replace /> },
// Phase 1
{ path: '/documents', element: <DocumentVaultPage /> },
@@ -112,9 +122,11 @@ export const router = createBrowserRouter([
// Phase 3 — Endorsement
{ path: '/endorsements', element: <EndorsementPage /> },
{ path: '/vessel-registration-dashboard', element: <VesselRegistrationDashboardPage /> },
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
// The registration wizard is the config-driven licensing flow; the old
// standalone wizard posted to endpoints that never existed.
{ path: '/vessel-registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
{ path: '/vessel-registration-dashboard', element: <Navigate to="/vessel-registration" replace /> },
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
// Legacy per-licence-type URLs. Each once had its own hand-written page
// that posted to a `/logistics-licenses/*` endpoint the API never had,
@@ -147,23 +159,14 @@ export const router = createBrowserRouter([
],
},
{ path: '/vessel-owner/login', element: <VesselOwnerLoginPage /> },
{ path: '/vessel-owner/register', element: <VesselOwnerRegisterPage /> },
{
element: (
<ProtectedRoute>
<I18nextProvider i18n={i18n}>
<VesselOwnerLayout />
</I18nextProvider>
</ProtectedRoute>
),
children: [
{ path: '/vessel-owner/dashboard', element: <VesselOwnerDashboardPage /> },
{ path: '/vessel-owner/registration', element: <VesselRegistrationPage /> },
{ path: '/vessel-owner/registration/apply', element: <VesselRegistrationApplicationPage /> },
{ path: '/vessel-owner/registration/transfer', element: <OwnershipTransferPage /> },
],
},
// The separate vessel-owner login/portal was mock-only and called auth
// endpoints that never existed; vessel owners are ordinary portal users.
{ path: '/vessel-owner/login', element: <Navigate to="/login" replace /> },
{ path: '/vessel-owner/register', element: <Navigate to="/signup" replace /> },
{ path: '/vessel-owner/dashboard', element: <Navigate to="/vessel-registration" replace /> },
{ path: '/vessel-owner/registration', element: <Navigate to="/vessel-registration" replace /> },
{ path: '/vessel-owner/registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
{ path: '/vessel-owner/registration/transfer', element: <Navigate to="/vessel-registration/transfer" replace /> },
{ path: '*', element: <Navigate to="/" replace /> },
]);