Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain

# Conflicts:
#	apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
#	apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing.helpers.ts
#	libs/auth/src/lib/components/AuthBootstrap.tsx
This commit is contained in:
mihretue
2026-08-20 11:41:06 +00:00
189 changed files with 36273 additions and 4832 deletions

View File

@@ -1,8 +1,10 @@
import { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
import { Center, Paper, Title, Text, Button } from '@mantine/core';
import { withTranslation } from 'react-i18next';
import type { WithTranslation } from 'react-i18next';
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
}
@@ -11,7 +13,7 @@ interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
class ErrorBoundaryBase extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
@@ -24,12 +26,13 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Title order={3} mb="sm">Something went wrong</Title>
<Title order={3} mb="sm">{t('errorBoundary.title')}</Title>
<Text c="dimmed" size="sm" mb="lg">
{this.state.error?.message || 'An unexpected error occurred.'}
{this.state.error?.message || t('errorBoundary.message')}
</Text>
<Button
fullWidth
@@ -38,7 +41,7 @@ export class ErrorBoundary extends Component<Props, State> {
window.location.href = '/';
}}
>
Reload page
{t('errorBoundary.reload')}
</Button>
</Paper>
</Center>
@@ -48,3 +51,5 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryBase);

View File

@@ -1,15 +1,16 @@
import Cookies from 'js-cookie';
import { Navigate } from 'react-router-dom';
import { LandingPage } from '@ema-platform/ui';
import { authStorage } from '@ema-platform/auth';
import { useAuthToken } from '@ema-platform/auth';
/**
* Public `/` — mounts the shared landing page with portal-specific routes.
* Auth state is read the same way ProtectedRoute does (token cookie or
* storage fallback) so the header can show "Go to dashboard" instead of
* Login/Sign Up without gating the route itself.
* Public `/`. Signed-in visitors skip the landing page entirely and go
* straight to the dashboard — the landing page is a front door for people
* who aren't in yet, not a screen for people who already are.
*/
export function LandingRoute() {
const token = authStorage.getToken() ?? Cookies.get('auth-token');
const token = useAuthToken();
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} signupHref="/signup" />;
if (token) return <Navigate to="/dashboard" replace />;
return <LandingPage primaryHref="/login" signupHref="/signup" />;
}

View File

@@ -31,6 +31,7 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// ---------------------------------------------------------------------------
// Types
@@ -275,6 +276,8 @@ export function BasicSafetyTrainingPage() {
const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0;
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Header */}

View File

@@ -8,7 +8,6 @@ import {
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Mock data
@@ -353,21 +353,12 @@ export function CertificatesPage() {
)}
</Paper>
{/* Preview modal */}
<Modal
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="95vw"
radius="lg"
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
url={previewUrl ?? ''}
title={previewTitle}
/>
</Stack>
);
}

View File

@@ -1,4 +1,5 @@
import { Badge, Progress, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
@@ -8,10 +9,12 @@ import {
} from '@ema-platform/api';
import type { LicenseApplication } from '@ema-platform/api';
export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
[
export function dashboardApplicationColumns(
t: TFunction,
): AdvancedColumn<LicenseApplication>[] {
return [
{
header: 'Application',
header: t('dashboard.table.application'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
@@ -24,13 +27,13 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
),
},
{
header: 'Licence',
header: t('applications.table.licence'),
cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
),
},
{
header: 'Status',
header: t('common.status'),
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
{STATUS_LABELS[row.original.status]}
@@ -38,7 +41,7 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
),
},
{
header: 'Progress',
header: t('applications.table.progress'),
size: 180,
cell: ({ row }) => (
<Progress
@@ -50,3 +53,4 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
),
},
];
}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import {
Alert,
Anchor,
@@ -10,7 +12,6 @@ import {
Center,
Container,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
@@ -36,7 +37,7 @@ import {
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
@@ -64,15 +65,20 @@ function daysUntil(date: string): number {
return Math.ceil(ms / 86_400_000);
}
function formatMoney(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return 'No fee';
function formatMoney(
amount: string | number | null,
currency: string,
t: TFunction,
): string {
if (amount === null || amount === '') return t('dashboard.noFee');
const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee';
if (!Number.isFinite(value)) return t('dashboard.noFee');
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function DashboardPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const displayName = useSelector(
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
state.auth.user?.name?.en || state.auth.user?.username || '',
@@ -108,11 +114,7 @@ export function DashboardPage() {
}
if (isLoading) {
return (
<Center h={400}>
<Loader />
</Center>
);
return <PageLoader label={t('dashboard.loading')} height={450} />;
}
return (
@@ -137,17 +139,15 @@ export function DashboardPage() {
color="orange"
radius="md"
icon={<IconClockHour4 size={18} />}
title={
expiringSoon.length === 1
? 'A licence is expiring soon'
: `${expiringSoon.length} licences are expiring soon`
}
title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
>
<Text size="sm">
{expiringSoon
.map(
(l) =>
`${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`,
.map((l) =>
t('dashboard.expiringSoon.detail', {
certificateNumber: l.certificateNumber,
days: daysUntil(l.expiryDate),
}),
)
.join(' · ')}
</Text>
@@ -165,9 +165,9 @@ export function DashboardPage() {
<GetStartedPanel />
) : (
<>
<Section title="My licences">
<Section title={t('dashboard.sections.myLicences.title')}>
{heldLicenses.length === 0 ? (
<EmptyCard message="No licence has been issued to you yet. One appears here once an application is approved and paid." />
<EmptyCard message={t('applications.licences.empty')} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => (
@@ -185,20 +185,20 @@ export function DashboardPage() {
</Section>
<Section
title="My applications"
title={t('dashboard.sections.myApplications.title')}
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
View all
{t('common.viewAll')}
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message="You have not filed any applications yet. Pick a licence below to get started." />
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
@@ -211,8 +211,8 @@ export function DashboardPage() {
)}
<Section
title="Apply for a licence"
description="Choose the licence that matches the service your company provides."
title={t('dashboard.sections.apply.title')}
description={t('dashboard.sections.apply.description')}
>
<LicenseCatalogue />
</Section>
@@ -232,10 +232,14 @@ function Hero({
applicationCount: number;
licenseCount: number;
}) {
const { t } = useTranslation();
const summary =
applicationCount === 0 && licenseCount === 0
? 'Apply for a maritime or logistics licence and track it through to issue.'
: `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`;
? t('dashboard.hero.summaryEmpty')
: t('dashboard.hero.summary', {
applications: t('dashboard.hero.applicationsCount', { count: applicationCount }),
licences: t('dashboard.hero.licencesCount', { count: licenseCount }),
});
return (
<Paper
@@ -250,10 +254,10 @@ function Hero({
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" style={{ opacity: 0.85 }}>
Ethiopian Maritime Authority
{t('app.authority')}
</Text>
<Title order={2} mt={4} c="white">
{displayName ? `Welcome back, ${displayName}` : 'Welcome back'}
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
</Title>
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
{summary}
@@ -280,6 +284,7 @@ function ActionRequired({
applications: LicenseApplication[];
navigate: (path: string) => void;
}) {
const { t } = useTranslation();
return (
<Card
withBorder
@@ -292,12 +297,12 @@ function ActionRequired({
<IconAlertTriangle size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Waiting on you
{t('dashboard.waitingOnYou')}
</Text>
</Group>
<Stack gap="xs">
{applications.map((app) => {
const detail = detailFor(app);
const detail = detailFor(app, t);
return (
<Paper key={app.id} radius="sm" p="sm" withBorder>
<Group justify="space-between" wrap="nowrap">
@@ -339,7 +344,10 @@ function ActionRequired({
}
/** What the applicant has to do next, and where that happens. */
function detailFor(app: LicenseApplication): {
function detailFor(
app: LicenseApplication,
t: TFunction,
): {
message: string;
cta: string;
color: string;
@@ -353,22 +361,24 @@ function detailFor(app: LicenseApplication): {
switch (app.status) {
case 'RESUBMIT_REQUIRED':
return {
message: 'A reviewer asked for corrections before this can proceed.',
cta: 'Fix now',
message: t('dashboard.actionRequired.messages.resubmit'),
cta: t('dashboard.actionRequired.cta.fixNow'),
color: 'orange',
path: wizard,
};
case 'PAYMENT_PENDING':
return {
message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`,
cta: 'Pay now',
message: t('dashboard.actionRequired.messages.paymentPending', {
amount: formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB', t),
}),
cta: t('dashboard.actionRequired.cta.payNow'),
color: 'yellow',
path: '/licensing/applications',
};
default:
return {
message: 'This application is still a draft and has not been filed.',
cta: 'Continue',
message: t('dashboard.actionRequired.messages.draft'),
cta: t('common.continue'),
color: 'blue',
path: wizard,
};
@@ -386,11 +396,12 @@ function StatRow({
activeLicenses: number;
expiringSoon: number;
}) {
const { t } = useTranslation();
const stats = [
{ label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' },
{ label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
];
return (
@@ -456,12 +467,13 @@ function ApplicationTable({
navigate: (path: string) => void;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const table = useServerTable();
const paged = table.paginate(applications);
return (
<AdvancedTable
tableName="My applications"
columns={dashboardApplicationColumns}
tableName={t('dashboard.sections.myApplications.title')}
columns={dashboardApplicationColumns(t)}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
@@ -486,6 +498,7 @@ function ApplicationTable({
* it, and a button that only scrolls the page is noise.
*/
function GetStartedPanel() {
const { t } = useTranslation();
return (
<Card withBorder radius="md" padding="xl">
<Group gap="md" wrap="nowrap" align="flex-start">
@@ -493,11 +506,9 @@ function GetStartedPanel() {
<IconCertificate size={24} stroke={1.5} />
</ThemeIcon>
<Box>
<Title order={4}>Get started</Title>
<Title order={4}>{t('dashboard.getStarted.title')}</Title>
<Text size="sm" c="dimmed" mt={4} maw={620}>
You have not filed an application yet. Choose the licence that
matches what your company does your applications and the licences
issued to you will appear here as you go.
{t('dashboard.getStarted.body')}
</Text>
</Box>
</Group>

View File

@@ -1,5 +1,6 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
/**
* Placeholder until this feature has a backend.
@@ -8,11 +9,13 @@ import { FeatureUnavailable } from '@ema-platform/ui';
* indistinguishable from real ones.
*/
export function DocumentVaultPage() {
const { t } = useTranslation();
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="My documents"
description="A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application."
title={t('featureUnavailable.documents.title')}
description={t('featureUnavailable.documents.description')}
/>
</Container>
);

View File

@@ -1,476 +1,225 @@
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
List,
Modal,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconEye,
IconFileDescription,
IconCircleX,
IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
TERMINAL_STATUSES,
extractErrorMessage,
useLocalized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { endorsementColumns } from './columns';
// ---------------------------------------------------------------------------
// Mock data — existing endorsement applications
// ---------------------------------------------------------------------------
/** What `/endorsements/my` returns. */
interface EndorsementsOverview {
issued: {
id: string;
endorsementNo: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
issued: string;
expiry: string;
status: string;
}[];
applications: {
id: string;
applicationId: string;
cocType: string;
foreignCocNo: string;
issuingCountry: string;
submitted: string;
status: string;
}[];
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
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>
);
}
// Keyed by the workflow's own status values so an unmapped one falls back to
// grey rather than rendering colourless.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
PAYMENT_PENDING: 'orange',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
ACTIVE: 'teal',
EXPIRED: 'red',
SUSPENDED: 'orange',
};
/**
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
* two application entry points (CoC / GOC), and the seafarer's endorsement
* applications and issued endorsements. The wizard itself is the
* config-driven licensing flow.
*/
export function EndorsementPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const {
data: licenses,
isFetching: fetchingLicenses,
refetch: refetchLicenses,
} = useGetMyLicensesQuery();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const issuedTable = useServerTable();
function humanStatus(status: string): string {
return status
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
function formatDate(value: string | null | undefined): string {
if (!value) return '';
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
const endorsementApplications = (applications?.items ?? []).filter((app) =>
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
);
const inFlight = endorsementApplications.filter(
(app) => !TERMINAL_STATUSES.includes(app.status),
);
const issued = (licenses?.items ?? []).filter((license) =>
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
const issuedPage = issuedTable.paginate(issued);
// blank PDF
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(
extractErrorMessage(error, t('endorsement.fetchFailed', 'Could not fetch endorsement')),
);
}
}
// ---------------------------------------------------------------------------
// Application wizard
// ---------------------------------------------------------------------------
interface Docs {
foreignCoc: File | null;
translation: File | null;
medical: File | null;
seamanBook: File | null;
photo: File | null;
}
function ApplicationWizard({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const [cocNo, setCocNo] = useState('');
const [issuer, setIssuer] = useState('');
const [country, setCountry] = useState('');
const [cocType, setCocType] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
const [submitted, setSubmitted] = useState(false);
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
if (submitted) {
return (
<Stack gap="lg" align="center" py="xl">
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
<Title order={3} ta="center">Application Submitted</Title>
<Text c="dimmed" ta="center" maw={400}>
Your endorsement application has been submitted. EMA officers will verify your documents
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
</Text>
<Button onClick={onDone}>Back to Endorsements</Button>
</Stack>
);
if (loadingProfile || loadingApplications) {
return <PageLoader label={t('endorsement.loading', 'Loading Endorsements…')} height={400} />;
}
return (
<Stack gap="lg">
<Stepper active={step} size="sm">
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
<Stepper.Step label="Upload Documents" description="Required documents" />
<Stepper.Step label="Payment" description="Pay endorsement fee" />
<Stepper.Step label="Review & Submit" description="Final check" />
</Stepper>
<Stack maw={860} mx="auto">
<Title order={2}>{t('endorsement.title', 'My Endorsements')}</Title>
{/* Step 0 — Foreign CoC details */}
{step === 0 && (
<Paper withBorder radius="lg" p="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
<Text fz="sm">
<strong>STCW Regulation I/10</strong> EMA will endorse your foreign CoC so it is
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
for the same period as your foreign CoC.
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} mb={6}>
{t('endorsement.eligibility.title', 'Eligibility')}
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? t('endorsement.eligibility.registered', {
defaultValue: 'Registered seafarer ({{number}})',
number: profile?.seafarerNumber,
})
: t(
'endorsement.eligibility.registrationRequired',
'Active seafarer registration required',
)
}
/>
</List>
</div>
<Stack gap="xs">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
>
{t('endorsement.endorseCoc', 'Endorse a CoC')}
</Button>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
>
{t('endorsement.endorseGoc', 'Endorse a GOC')}
</Button>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
{t('endorsement.registrationNotice.prefix', 'Complete your')}{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
{t('endorsement.registrationNotice.link', 'seafarer registration')}
</Text>{' '}
{t(
'endorsement.registrationNotice.suffix',
'first — endorsement applications are refused without it.',
)}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
</SimpleGrid>
</Paper>
)}
)}
</Card>
{/* Step 1 — Documents */}
{step === 1 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
<Text fz="sm">
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
All documents must be clear, legible, and complete.
</Text>
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Required Documents</Text>
<Stack gap="md">
{[
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
].map((slot) => (
<FileInput
key={slot.key}
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
placeholder="Click to upload"
leftSection={<IconUpload size={14} />}
value={docs[slot.key]}
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
accept=".pdf,.jpg,.jpeg,.png"
clearable
/>
))}
</Stack>
</Paper>
{/* Upload checklist */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
<Stack gap={4}>
{[
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
{ label: 'Medical Cert', done: !!docs.medical },
{ label: 'Seaman Book', done: !!docs.seamanBook },
{ label: 'Photo', done: !!docs.photo },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
</ThemeIcon>
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>{t('endorsement.inProgress', '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">
{localized(app.licenseType?.name)}
</Text>
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{t(`applications.status.${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'
? t('applications.actions.continue', 'Continue')
: t('applications.actions.view', 'View')}
</Button>
</Group>
))}
</Stack>
</Paper>
</Group>
</Card>
))}
</Stack>
)}
{/* Step 2 — Payment */}
{step === 2 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Endorsement Fee</Text>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb="xs">
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={600}>ETB {amount}</Text>
</Group>
))}
<Divider my="xs" />
<Group justify="space-between">
<Text fw={800}>Total</Text>
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
</Group>
</Paper>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
<Text fz="sm">
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
</Text>
</Alert>
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
</Paper>
)}
{/* Step 3 — Review */}
{step === 3 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="lg">Review Your Application</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
{[
['CoC Number', cocNo],
['Country', country],
['Issuer', issuer],
['CoC Type', cocType],
['Issue Date', issueDate],
['Expiry Date', expiryDate],
].map(([label, value]) => (
<div key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
))}
</SimpleGrid>
<Divider mb="md" />
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
<List spacing="xs" size="sm">
{[
{ label: 'Foreign CoC', file: docs.foreignCoc },
{ label: 'Medical Certificate', file: docs.medical },
{ label: 'Seaman Book', file: docs.seamanBook },
{ label: 'Photo', file: docs.photo },
{ label: 'Translation', file: docs.translation },
].map(({ label, file }) => file && (
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
</List.Item>
))}
</List>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
<Text fz="xs">
By submitting you confirm that all information is accurate and the documents are genuine.
Providing false information is an offence under the Maritime Code.
</Text>
</Alert>
</Paper>
)}
{/* Navigation */}
<Group justify="space-between" mt="md">
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
Back
</Button>
{step < 3 ? (
<Button
rightSection={<IconArrowRight size={14} />}
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
onClick={() => setStep(s => s + 1)}
>
Next
</Button>
) : (
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
Submit Application
</Button>
)}
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function EndorsementPage() {
const navigate = useNavigate();
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
const { data } = useApiQuery<EndorsementsOverview>({
url: '/endorsements/my',
method: 'GET',
});
const endorsementApps = data?.applications ?? [];
const issuedEndorsements = data?.issued ?? [];
if (applying) {
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
<div>
<Title order={3}>Apply for Endorsement</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag State Endorsement of Foreign CoC</Text>
</div>
</Group>
<ApplicationWizard onDone={() => setApplying(false)} />
<Stack gap="xs">
<Title order={4}>{t('endorsement.issuedEndorsements', 'Issued endorsements')}</Title>
<AdvancedTable
tableName={t('endorsement.issuedEndorsements', 'Issued endorsements')}
columns={endorsementColumns({ t, showDate, localized, onDownload: download })}
data={issuedPage.rows}
itemCount={issuedPage.itemCount}
pageIndex={issuedPage.pageIndex}
onPageChange={issuedTable.setPageIndex}
pageSize={issuedTable.pageSize}
refresh={refetchLicenses}
isLoading={fetchingLicenses}
emptyText={t('endorsement.emptyIssued', 'No endorsements issued yet.')}
/>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Endorsements</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag-state endorsement of foreign-issued Certificates of Competency</Text>
</div>
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
Apply for Endorsement
</Button>
</Group>
{/* Info panel */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} fz="sm">What is an Endorsement?</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
{[
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 1015 working days after all documents are verified.' },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
<Text fz="xs" fw={700}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Group>
</Paper>
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsement Applications</Text>
{endorsementApps.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
) : (
<Stack gap="sm">
{endorsementApps.map((app) => (
<Paper key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fz="sm" fw={700}>{app.cocType}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={STATUS_COLOR[app.status] ?? "gray"} variant="light">{humanStatus(app.status)}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={STATUS_COLOR[app.status] ?? "gray"} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{humanStatus(app.status)}</Text>
</Alert>
</Paper>
))}
</Stack>
)}
</Paper>
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{issuedEndorsements.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{issuedEndorsements.map((end) => (
<Card key={end.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{end.cocType}</Text>
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[end.status] ?? "gray"} variant="light">{humanStatus(end.status)}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs" mb="sm">
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
</SimpleGrid>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewId}
onClose={() => setPreviewId(null)}
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
size="xl"
radius="lg"
>
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
</Modal>
</Stack>
);
}
export default EndorsementPage;

View File

@@ -0,0 +1,71 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconCertificate } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
interface EndorsementColumnsArgs {
t: TFunction;
showDate: (date: string) => string;
localized: (value: Bilingual | undefined) => string;
onDownload: (licenseId: string) => void;
}
export function endorsementColumns({
t,
showDate,
localized,
onDownload,
}: EndorsementColumnsArgs): AdvancedColumn<IssuedLicense>[] {
return [
{
header: t('endorsement.columns.certificateNumber', 'Certificate №'),
accessorKey: 'certificateNumber',
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.certificateNumber}
</Text>
),
},
{
header: t('endorsement.columns.type', 'Type'),
cell: ({ row }) => localized(row.original.licenseType?.name),
},
{
header: t('endorsement.columns.issued', 'Issued'),
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: t('endorsement.columns.expires', 'Expires'),
cell: ({ row }) => showDate(row.original.expiryDate),
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
>
{t(
`endorsement.columns.licenseStatus.${row.original.status}`,
row.original.status,
)}
</Badge>
),
},
{
header: '',
cell: ({ row }) => (
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => onDownload(row.original.id)}
>
{t('common.download')}
</Button>
),
},
];
}

View File

@@ -1,5 +1,6 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
@@ -21,17 +22,20 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
export function registrationColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
}): AdvancedColumn<MyRegistration>[] {
export function registrationColumns(
t: TFunction,
deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
onStartExam: (registration: MyRegistration) => void;
},
): AdvancedColumn<MyRegistration>[] {
return [
{
header: 'Admission',
header: t('exams.columns.admission'),
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.admissionNumber}
@@ -39,19 +43,19 @@ export function registrationColumns(deps: {
),
},
{
header: 'Examination',
header: t('exams.columns.examination'),
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
},
{
header: 'Date',
header: t('exams.columns.date'),
cell: ({ row }) => deps.showDate(row.original.exam?.date),
},
{
header: 'Venue',
header: t('exams.columns.venue'),
cell: ({ row }) => row.original.exam?.venue ?? '—',
},
{
header: 'Attempt',
header: t('exams.columns.attempt'),
cell: ({ row }) => (
<Badge
size="sm"
@@ -59,25 +63,25 @@ export function registrationColumns(deps: {
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
>
{row.original.kind === 'RETAKE'
? `Retake · ${row.original.attemptNumber}`
: 'First sitting'}
? t('exams.columns.retake', { n: row.original.attemptNumber })
: t('exams.columns.firstSitting')}
</Badge>
),
},
{
header: 'Attendance',
header: t('exams.columns.attendance'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
>
{row.original.attendanceStatus}
{t(`exams.columns.attendanceStatus.${row.original.attendanceStatus}`)}
</Badge>
),
},
{
header: 'Slip',
header: t('exams.columns.slip'),
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button
@@ -86,12 +90,12 @@ export function registrationColumns(deps: {
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)}
>
Slip
{t('exams.columns.slip')}
</Button>
) : null,
},
{
header: 'Exam',
header: t('exams.columns.exam'),
cell: ({ row }) => {
const exam = row.original.exam;
const attemptStatus = row.original.attempt?.status;
@@ -100,14 +104,14 @@ export function registrationColumns(deps: {
if (attemptStatus === 'SUBMITTED') {
return (
<Badge size="sm" variant="light" color="teal">
Completed
{t('exams.columns.completed')}
</Badge>
);
}
if (attemptStatus === 'EXPIRED') {
return (
<Badge size="sm" variant="light" color="red">
Time expired
{t('exams.columns.timeExpired')}
</Badge>
);
}
@@ -121,7 +125,9 @@ export function registrationColumns(deps: {
leftSection={<IconPlayerPlay size={13} />}
onClick={() => deps.onStartExam(row.original)}
>
{attemptStatus === 'IN_PROGRESS' ? 'Resume exam' : 'Take exam'}
{attemptStatus === 'IN_PROGRESS'
? t('exams.columns.resumeExam')
: t('exams.columns.takeExam')}
</Button>
);
},
@@ -129,25 +135,28 @@ export function registrationColumns(deps: {
];
}
export function resultColumns(deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
appeals: MyAppeal[];
onAppeal: (result: MyResult) => void;
}): AdvancedColumn<MyResult>[] {
export function resultColumns(
t: TFunction,
deps: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
localized: (value: Bilingual | undefined) => string;
showDate: (value: string | null | undefined) => string;
appeals: MyAppeal[];
onAppeal: (result: MyResult) => void;
},
): AdvancedColumn<MyResult>[] {
return [
{
header: 'Examination',
header: t('exams.columns.examination'),
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
},
{
header: 'Published',
header: t('exams.columns.published'),
cell: ({ row }) => deps.showDate(row.original.publishedAt),
},
{
header: 'Score',
header: t('exams.columns.score'),
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.totalScore}
@@ -155,23 +164,24 @@ export function resultColumns(deps: {
),
},
{
header: 'Outcome',
header: t('exams.columns.outcome'),
cell: ({ row }) => (
<Badge
variant="light"
color={row.original.status === 'PASSED' ? 'teal' : 'red'}
>
{row.original.status}
{t(`exams.columns.outcomeStatus.${row.original.status}`)}
</Badge>
),
},
{
header: 'Appeal',
header: t('exams.columns.appeal'),
cell: ({ row }) => {
const appeal = deps.appeals.find((a) => a.resultId === row.original.id);
return appeal ? (
<Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status}
{appeal.appealNumber} ·{' '}
{t(`exams.columns.appealStatus.${appeal.status}`)}
</Badge>
) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button
@@ -181,7 +191,7 @@ export function resultColumns(deps: {
leftSection={<IconGavel size={13} />}
onClick={() => deps.onAppeal(row.original)}
>
Appeal
{t('exams.columns.appeal')}
</Button>
) : null;
},

View File

@@ -5,7 +5,6 @@ import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
@@ -13,7 +12,8 @@ import {
Title,
} from '@mantine/core';
import { IconClipboardList } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
useApiQuery,
@@ -82,6 +82,7 @@ export interface MyAppeal {
* when a mark looks wrong.
*/
export function ExamsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
@@ -127,18 +128,21 @@ export function ExamsPage() {
method: 'POST',
}).unwrap()) as { admissionNumber?: string };
notify.success(
`Registered — admission number ${result.admissionNumber ?? 'issued'}`,
t('exams.notify.registered', {
admissionNumber:
result.admissionNumber ?? t('exams.notify.admissionNumberPending'),
}),
);
refetch();
} catch (error) {
const key = extractErrorMessage(error, 'Could not register');
const key = extractErrorMessage(error, t('exams.notify.registerFailed'));
notify.error(
key === 'seafarer_registration_required'
? 'An active seafarer registration is required to sit examinations.'
? t('exams.notify.seafarerRequired')
: key === 'already_registered_for_exam'
? 'You are already registered for this session.'
? t('exams.notify.alreadyRegistered')
: key === 'subject_already_passed'
? 'You have already passed this subject — a resit is not needed.'
? t('exams.notify.alreadyPassed')
: key,
);
}
@@ -152,7 +156,7 @@ export function ExamsPage() {
);
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not generate the admission slip'),
extractErrorMessage(error, t('exams.notify.slipFailed')),
);
}
};
@@ -165,28 +169,26 @@ export function ExamsPage() {
method: 'POST',
body: { reason: appealReason.trim() },
}).unwrap()) as { appealNumber?: string };
notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`);
notify.success(
t('exams.notify.appealSubmitted', { appealNumber: appeal.appealNumber ?? '' }),
);
setAppealFor(null);
setAppealReason('');
refetchAppeals();
} catch (error) {
const key = extractErrorMessage(error, 'Could not submit the appeal');
const key = extractErrorMessage(error, t('exams.notify.appealFailed'));
notify.error(
key.startsWith('appeal_window_closed')
? `The appeal window (${key.split(':')[1] ?? ''} days from publication) has closed.`
? t('exams.notify.appealWindowClosed', { days: key.split(':')[1] ?? '' })
: key === 'appeal_already_open'
? 'An appeal on this result is already being considered.'
? t('exams.notify.appealAlreadyOpen')
: key,
);
}
};
if (loadingOpen || loadingMine || loadingResults) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
return <PageLoader label={t('exams.loading')} height={400} />;
}
const pagedRegistrations = registrationTable.paginate(mine ?? []);
@@ -194,14 +196,14 @@ export function ExamsPage() {
return (
<Stack maw={900} mx="auto">
<Title order={2}>Examinations</Title>
<Title order={2}>{t('exams.title')}</Title>
<Stack gap="xs">
<Title order={4}>Open sessions</Title>
<Title order={4}>{t('exams.openSessions')}</Title>
{(open ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No upcoming sessions are open for registration.
{t('exams.noOpenSessions')}
</Text>
</Card>
) : (
@@ -218,7 +220,7 @@ export function ExamsPage() {
</div>
{registeredExamIds.has(exam.id) ? (
<Badge color="teal" variant="light">
Registered
{t('exams.registered')}
</Badge>
) : (
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
@@ -228,7 +230,7 @@ export function ExamsPage() {
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
{t('exams.register')}
</Button>
</RequirePermission>
)}
@@ -239,10 +241,10 @@ export function ExamsPage() {
</Stack>
<Stack gap="xs">
<Title order={4}>My registrations</Title>
<Title order={4}>{t('exams.myRegistrations')}</Title>
<AdvancedTable<MyRegistration>
tableName="My registrations"
columns={registrationColumns({
tableName={t('exams.myRegistrations')}
columns={registrationColumns(t, {
can,
localized,
showDate,
@@ -255,15 +257,15 @@ export function ExamsPage() {
onPageChange={registrationTable.setPageIndex}
pageSize={registrationTable.pageSize}
refresh={refetch}
emptyText="No exam registrations yet."
emptyText={t('exams.noRegistrations')}
/>
</Stack>
<Stack gap="xs">
<Title order={4}>My results</Title>
<Title order={4}>{t('exams.myResults')}</Title>
<AdvancedTable<MyResult>
tableName="My results"
columns={resultColumns({
tableName={t('exams.myResults')}
columns={resultColumns(t, {
can,
localized,
showDate,
@@ -276,40 +278,42 @@ export function ExamsPage() {
onPageChange={resultTable.setPageIndex}
pageSize={resultTable.pageSize}
refresh={refetchResults}
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them."
emptyText={t('exams.noResults')}
/>
</Stack>
<Modal
opened={Boolean(appealFor)}
onClose={() => setAppealFor(null)}
title="Request a review of this result"
title={t('exams.appealModal.title')}
radius="lg"
>
<Stack>
<Text size="sm" c="dimmed">
Explain what you believe went wrong with the marking or the
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
Appeals must be lodged within 14 days of publication.
{t('exams.appealModal.body', {
examTitle:
localized(appealFor?.exam?.title) ||
t('exams.appealModal.defaultExamTitle'),
})}
</Text>
<Textarea
minRows={4}
autosize
label="Grounds for appeal"
label={t('exams.appealModal.reasonLabel')}
value={appealReason}
onChange={(event) => setAppealReason(event.currentTarget.value)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setAppealFor(null)}>
Cancel
{t('common.cancel')}
</Button>
<Button
loading={appealing}
disabled={appealReason.trim().length === 0}
onClick={submitAppeal}
>
Submit appeal
{t('exams.appealModal.submit')}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,141 @@
import {
Badge,
Divider,
Group,
Grid,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import {
conditionHolds,
displayFieldValue,
type Attachment,
type FormFieldConfig,
type FormSectionConfig,
type LicenseTypeRequirements,
} from "@ema-platform/api";
import { useDateDisplayer } from "@ema-platform/shared";
import { useTranslation } from "react-i18next";
import { DocumentSlots } from "./DocumentSlots";
interface Props {
/** Every form section (not just the "review" group) in wizard-step order. */
sections: FormSectionConfig[];
formData: Record<string, Record<string, unknown>>;
localized: (value: { en?: string; am?: string } | undefined) => string;
config: LicenseTypeRequirements;
attachments: Attachment[];
applicationId: string;
}
/**
* Read-only "what was filed" view for a submitted application: every
* answered section as a labelled table, then the uploaded documents.
*
* Shown instead of the wizard once there is nothing left to step through —
* the stepper is for filling a form in, not for re-reading one that is
* already someone else's decision to make.
*/
export function ApplicationSummary({
sections,
formData,
localized,
config,
attachments,
applicationId,
}: Props) {
const showDate = useDateDisplayer();
const { i18n } = useTranslation();
// Shared with the officer's review screen, so the applicant and the reviewer
// never read the same answer two different ways.
const display = (field: FormFieldConfig, raw: unknown) =>
displayFieldValue(field, raw, {
language: i18n.language,
showDate,
currency: config.feeCurrency,
}) || "—";
return (
<Stack gap="md">
{sections.map((section) => {
const fields = (section.fields ?? []).filter((f) =>
conditionHolds(f.showWhen, formData),
);
if (fields.length === 0) return null;
return (
<Paper withBorder p="lg" radius="md" key={section.key}>
<Group justify="space-between" align="center" mb="xs">
<Title order={5}>{localized(section.title)}</Title>
<Badge variant="light" color="gray" size="sm">
{fields.length} {fields.length === 1 ? "detail" : "details"}
</Badge>
</Group>
{localized(section.description) && (
<Text fz="xs" c="dimmed" mb="sm">
{localized(section.description)}
</Text>
)}
<Divider mb="md" />
{/* Label above value in two columns — a definition list reads far
better than a bordered grid when most answers are short. */}
<Grid gutter="md">
{fields.map((field) => {
const value = display(
field,
formData[section.key]?.[field.key],
);
const answered = value !== "—";
return (
<Grid.Col
span={{
base: 12,
sm: field.type === "TEXTAREA" ? 12 : 6,
}}
key={field.key}
>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{localized(field.label)}
</Text>
<Text
fz="sm"
mt={2}
c={answered ? undefined : "dimmed"}
fs={answered ? undefined : "italic"}
style={{ wordBreak: "break-word" }}
>
{answered ? value : "Not provided"}
</Text>
</Grid.Col>
);
})}
</Grid>
</Paper>
);
})}
<Paper withBorder p="lg" radius="md">
<Title order={5} mb="sm">
Documents
</Title>
<Divider mb="md" />
<DocumentSlots
requirements={config.documentRequirements}
attachments={attachments}
formData={formData}
ownerType="APPLICATION"
ownerId={applicationId}
readOnly
onUploaded={() => {
// Read-only here — nothing to react to, but DocumentSlots
// requires the callback.
}}
/>
</Paper>
</Stack>
);
}

View File

@@ -1,6 +1,7 @@
import {
Checkbox,
Grid,
Input,
NumberInput,
Select,
Textarea,
@@ -14,6 +15,8 @@ import {
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { LocationPicker } from '../../location/components/LocationPicker';
interface Props {
section: FormSectionConfig;
@@ -97,6 +100,7 @@ export function ConfigDrivenSection({
onVesselSelected,
}: Props) {
const localized = useLocalized();
const { t } = useTranslation();
const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
@@ -127,10 +131,32 @@ export function ConfigDrivenSection({
// own vessel register, so this overrides whatever type the backend
// configured, the same way nationality overrides SELECT above.
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
// Stores a location-tree uuid, so it needs the cascading picker the
// profile's Address tab uses — configured as TEXT because the field
// types have no LOCATION member, which left a required field asking
// the applicant to type a uuid by hand.
const isLocation = field.key === 'locationId' || labelEn.trim() === 'location';
return (
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
{isNationality ? (
{isLocation ? (
// LocationPicker renders its own cascade of Selects and takes no
// label/error props, so the wrapper supplies them.
<Input.Wrapper
label={label}
description={localized(field.helpText) || undefined}
withAsterisk={field.required}
error={error}
>
<LocationPicker
value={(value as string) ?? undefined}
onChange={(id) => onChange(field.key, id)}
required={field.required}
maxDepth={3}
disabled={common.disabled}
/>
</Input.Wrapper>
) : isNationality ? (
<CountrySelect
{...common}
demonym
@@ -140,7 +166,7 @@ export function ConfigDrivenSection({
) : isVesselPicker ? (
<Select
{...common}
placeholder="Select a registered vessel"
placeholder={t('licensing.vesselPicker.placeholder')}
data={vessels.map((v) => ({ value: v.id, label: `${v.name}${v.registrationNumber}` }))}
value={(value as string) ?? null}
onChange={(v) => {

View File

@@ -1,6 +1,5 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
@@ -15,7 +14,6 @@ import {
IconAlertTriangle,
IconCheck,
IconFileUpload,
IconTrash,
} from '@tabler/icons-react';
import {
conditionHolds,
@@ -24,6 +22,8 @@ import {
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -59,8 +59,12 @@ export function DocumentSlots({
readOnly,
}: Props) {
const localized = useLocalized();
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const resetRefs = useRef<Record<string, () => void>>({});
const required = requirements.filter(
@@ -73,7 +77,11 @@ export function DocumentSlots({
async function handle(documentKey: string, file: File | null) {
if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
setError(
t('licensing.msg.fileTooLarge', {
size: `${(file.size / 1024 / 1024).toFixed(1)}MB`,
}),
);
resetRefs.current[documentKey]?.();
return;
}
@@ -96,6 +104,7 @@ export function DocumentSlots({
{required.map((requirement) => {
const existing = attachments.find((a) => a.documentKey === requirement.key);
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
@@ -121,17 +130,17 @@ export function DocumentSlots({
</Text>
{requirement.mode === 'CONDITIONAL' && (
<Badge size="xs" variant="light" color="grape">
conditional
{t('licensing.documents.conditional')}
</Badge>
)}
{requirement.mode === 'OPTIONAL' && (
<Badge size="xs" variant="light" color="gray">
optional
{t('common.optional')}
</Badge>
)}
{uploaded && !flagRemark && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
{t('licensing.documents.uploaded')}
</Badge>
)}
</Group>
@@ -143,21 +152,24 @@ export function DocumentSlots({
)}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
Officer: {flagRemark}
{t('licensing.documents.officerRemark', { name: flagRemark })}
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
{fileUrl && (
<Button
size="xs"
variant="subtle"
component="a"
href={existing.files[0].url}
target="_blank"
onClick={() =>
setPreview({
url: fileUrl,
title: localized(requirement.name),
})
}
>
View
{t('licensing.documents.view')}
</Button>
)}
{!locked && (
@@ -175,14 +187,16 @@ export function DocumentSlots({
variant={uploaded ? 'light' : 'filled'}
leftSection={
busy === requirement.key ? (
<Loader size={12} />
<Loader size={12} type="oval" />
) : (
<IconFileUpload size={14} />
)
}
disabled={busy === requirement.key}
>
{uploaded ? 'Replace' : 'Upload'}
{uploaded
? t('licensing.documents.replace')
: t('licensing.documents.upload')}
</Button>
)}
</FileButton>
@@ -192,6 +206,12 @@ export function DocumentSlots({
</Card>
);
})}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Stack>
);
}

View File

@@ -19,6 +19,7 @@ import {
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { useTranslation } from 'react-i18next';
import {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
@@ -36,6 +37,7 @@ import {
*/
export function useRenewLicense() {
const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isRenewing }] =
useCreateApplicationMutation();
@@ -50,7 +52,7 @@ export function useRenewLicense() {
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), 'Could not start the renewal');
notify.error(extractErrorMessage(err), t('licensing.card.renewFailed'));
}
}
@@ -82,13 +84,14 @@ export function LicenseCard({
const renewable = license.renewable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
const { t } = useTranslation();
return (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{localized(license.licenseType?.name) || 'Licence'}
{localized(license.licenseType?.name) || t('licensing.card.fallbackName')}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber}
@@ -99,7 +102,7 @@ export function LicenseCard({
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
>
{expired ? 'Expired' : license.status}
{expired ? t('licensing.card.expired') : license.status}
</Badge>
</Group>
@@ -107,11 +110,10 @@ export function LicenseCard({
<Group justify="space-between" align="center">
<Box>
<Text size="xs" c="dimmed">
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}>
{showDate(license.expiryDate)}
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
</Text>
</Box>
<RequirePermission
@@ -121,7 +123,7 @@ export function LicenseCard({
]}
hideOnly
>
<Tooltip label="Download certificate">
<Tooltip label={t('licensing.card.downloadCertificate')}>
<ActionIcon
variant="light"
radius="md"
@@ -150,8 +152,8 @@ export function LicenseCard({
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
? t('licensing.card.renewExpired')
: t('licensing.card.renewDays', { count: days })}
</Button>
</RequirePermission>
)}

View File

@@ -32,6 +32,7 @@ import {
} from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useTranslation } from 'react-i18next';
/**
* The licence catalogue an applicant chooses from, grouped by category.
@@ -52,16 +53,17 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
WAIVER_SERVICES: IconShieldOff,
};
function formatFee(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return 'No fee';
function formatFee(amount: string | number | null, currency: string, noFeeLabel: string): string {
if (amount === null || amount === '') return noFeeLabel;
const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee';
if (!Number.isFinite(value)) return noFeeLabel;
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function LicenseCatalogue() {
const navigate = useNavigate();
const localized = useLocalized();
const { t } = useTranslation();
const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery();
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
@@ -79,10 +81,15 @@ 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)
// Logistics licences only: this is the operator catalogue, not the
// seafarer certificate or vessel/seafarer document catalogue — those
// have their own entry points. `familyKind` is the real data-model
// classification (set on the type at seed time); `requiresOperatorMode`
// was the proxy this used before that column existed and happened to
// agree for every type seeded so far, but a type can only be trusted to
// stay in sync with the catalogue it belongs in if the catalogue reads
// its actual family instead of a flag with a different purpose.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
// 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.
@@ -116,19 +123,17 @@ export function LicenseCatalogue() {
<IconBuildingWarehouse size={18} />
</ThemeIcon>
<Text size="sm" fw={600}>
Tell us what you operate as
{t('licensing.catalogue.emptyTitle')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={520}>
Licences are offered against your mode of operation freight
forwarder, shipping agent, multimodal transport operator and so on.
Choose yours and the licences you can apply for appear here.
{t('licensing.catalogue.emptyBody')}
</Text>
<Group gap="sm" mt="xs">
<Button size="xs" onClick={() => navigate('/profile#operations')}>
Set my operations
{t('licensing.catalogue.setOperations')}
</Button>
<Button size="xs" variant="subtle" onClick={() => setShowAll(true)}>
Browse all licences
{t('licensing.catalogue.browseAll')}
</Button>
</Group>
</Stack>
@@ -145,8 +150,7 @@ export function LicenseCatalogue() {
<IconFileText size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
No licence types are available yet. Contact EMA if you were
expecting one.
{t('licensing.catalogue.noneAvailable')}
</Text>
</Stack>
</Center>
@@ -167,10 +171,10 @@ export function LicenseCatalogue() {
{showAll && hasDeclared && (
<Group gap="xs">
<Text size="xs" c="dimmed">
Showing every licence, including ones outside your operations.
{t('licensing.catalogue.showingAll')}
</Text>
<Anchor size="xs" onClick={() => setShowAll(false)}>
Show only mine
{t('licensing.catalogue.showOnlyMine')}
</Anchor>
</Group>
)}
@@ -188,8 +192,8 @@ export function LicenseCatalogue() {
{orphans.length > 0 && (
<CategoryGroup
icon={IconFileText}
title="Other licences"
description="Licence types that have not been assigned a category."
title={t('licensing.catalogue.otherLicences')}
description={t('licensing.catalogue.otherLicencesDescription')}
licenseTypes={orphans}
canApply={canApply}
onSelect={select}
@@ -198,10 +202,10 @@ export function LicenseCatalogue() {
{hasDeclared && !showAll && (
<Group gap="xs">
<Text size="xs" c="dimmed">
Only licences matching your declared operations are shown.
{t('licensing.catalogue.showingMine')}
</Text>
<Anchor size="xs" onClick={() => setShowAll(true)}>
Browse all licences
{t('licensing.catalogue.browseAll')}
</Anchor>
</Group>
)}
@@ -261,6 +265,7 @@ function LicenseTypeCard({
onSelect: (type: LicenseType) => void;
}) {
const localized = useLocalized();
const { t } = useTranslation();
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return (
@@ -293,23 +298,23 @@ function LicenseTypeCard({
<Box>
<Group gap={6} mt="sm">
<Badge size="sm" variant="light" color="emaPrimary">
{formatFee(type.feeNewApplication, type.feeCurrency)}
{formatFee(type.feeNewApplication, type.feeCurrency, t('licensing.catalogue.noFee'))}
</Badge>
{capital && (
<Tooltip label="Minimum capital that must be evidenced by a bank letter">
<Tooltip label={t('licensing.catalogue.capitalTooltip')}>
<Badge size="sm" variant="light" color="gray">
Capital {capital.toLocaleString('en-US')}
{t('licensing.catalogue.capitalBadge', { amount: capital.toLocaleString('en-US') })}
</Badge>
</Tooltip>
)}
{type.issuesCertificate ? (
<Badge size="sm" variant="light" color="teal">
{type.validityMonths} months
{t('licensing.catalogue.validityBadge', { months: type.validityMonths })}
</Badge>
) : (
<Tooltip label="Concludes with an EMA decision rather than a certificate">
<Tooltip label={t('licensing.catalogue.evaluationTooltip')}>
<Badge size="sm" variant="light" color="gray">
Evaluation only
{t('licensing.catalogue.evaluationOnly')}
</Badge>
</Tooltip>
)}
@@ -323,7 +328,9 @@ function LicenseTypeCard({
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
{canApply ? 'Start application' : 'Add to my operations'}
{canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
</Button>
</RequirePermission>
</Box>

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
import { useState } from 'react';
import { ActionIcon, Button, FileButton, Group, Loader } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { IconCheck, IconEye } from '@tabler/icons-react';
import {
useLocalized,
uploadDocument,
useGetAttachmentsQuery,
type StaffEvidenceRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -30,26 +32,34 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
ownerId: staffId,
});
const [busy, setBusy] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const localized = useLocalized();
const { t } = useTranslation();
if (!evidence?.length) return null;
return (
<Group gap="xs">
{evidence.map((item) => {
const uploaded = attachments.some(
const attachment = attachments.find(
(a) => a.documentKey === item.docKey && a.files?.length,
);
const uploaded = Boolean(attachment);
const fileUrl = attachment?.files?.[0]?.url;
return (
<Group key={item.docKey} gap={4} wrap="nowrap">
<FileButton
key={item.docKey}
accept="application/pdf,image/jpeg,image/png"
onChange={async (file) => {
if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
notifications.show({
color: 'red',
message: `File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`,
message: t('licensing.msg.fileTooLarge', {
size: `${(file.size / 1024 / 1024).toFixed(1)}MB`,
}),
});
return;
}
@@ -74,7 +84,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
disabled={readOnly || busy === item.docKey}
leftSection={
busy === item.docKey ? (
<Loader size={10} />
<Loader size={10} type="oval" />
) : uploaded ? (
<IconCheck size={12} />
) : undefined
@@ -85,8 +95,27 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
</Button>
)}
</FileButton>
{uploaded && fileUrl && (
<ActionIcon
size="sm"
variant="subtle"
aria-label={t('licensing.documents.view', 'View')}
onClick={() =>
setPreview({ url: fileUrl, title: localized(item.label) })
}
>
<IconEye size={14} />
</ActionIcon>
)}
</Group>
);
})}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Group>
);
}

View File

@@ -13,9 +13,11 @@ interface LocationPickerProps {
required?: boolean;
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
maxDepth?: number;
/** Locks every level — a submitted application, or a section under review. */
disabled?: boolean;
}
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth, disabled }: LocationPickerProps) {
const { t } = useTranslation();
const localized = useLocalized();
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
@@ -204,7 +206,9 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
{levels.map((levelIdx) => {
const options = buildOptions(levelIdx);
const currentValue = selectedChain[levelIdx]?.id ?? null;
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
// Either the whole picker is locked, or this level has no parent
// choice yet to narrow it.
const isDisabled = disabled || (levelIdx > 0 && !selectedChain[levelIdx - 1]);
return (
<Select

View File

@@ -1,24 +1,16 @@
export interface NamePair {
en: string;
am: string;
}
/**
* Re-exported from the shared contract so both apps read one definition.
*
* The portal and backoffice each kept their own copy of this model and drifted:
* the two `Location` shapes disagreed on `locationType`/`children`/timestamps,
* and `NamePair` dropped the `om`/`so` names the backend stores. Importers keep
* this path; the model itself now lives in `@ema-platform/api`.
*/
export type {
Location,
LocationType,
ListResponse,
} from '@ema-platform/api';
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
}
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
export type { Bilingual as NamePair } from '@ema-platform/api';

View File

@@ -1,339 +0,0 @@
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Timeline,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconAlertTriangle,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
interface MedicalCert {
id: string;
issuedBy: string;
issuedDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
restrictions: string;
/** Days left, computed server-side so every screen agrees on the date. */
daysRemaining?: number;
}
/** The medical card's whole state, as `/medical/my` returns it. */
interface MedicalOverview {
current: MedicalCert | null;
history: MedicalCert[];
warningDays: number;
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
};
function daysUntil(dateStr: string): number {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function MedicalCertificatePage() {
// The card's whole state comes from one call: the current certificate, the
// ones before it, and the validity the server computed. Deriving "expiring"
// in the browser would let a wrong client clock disagree with the gate that
// blocks an application.
const { data: medical } = useApiQuery<MedicalOverview>({
url: '/medical/my',
method: 'GET',
});
const current = medical?.current ?? null;
const history = medical?.history ?? [];
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [doctorName, setDoctorName] = useState('');
const [issuedDate, setIssuedDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const resetRef = useRef<() => void>(null);
// Server's count where it gave one: it is the same figure the eligibility
// gate uses, and a browser clock that is wrong or in another timezone would
// otherwise show a different number than the officer sees.
const days = current
? (current.daysRemaining ?? daysUntil(current.expiryDate))
: 0;
const progressVal = current
? Math.max(0, Math.min(100, (days / 730) * 100))
: 0;
const handleSubmit = async () => {
if (!uploadedFile || !issuedDate || !expiryDate) {
notify.error('Please fill all fields and upload the certificate file.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1200));
setSubmitting(false);
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
setUploadedFile(null);
setDoctorName('');
setIssuedDate('');
setExpiryDate('');
resetRef.current?.();
};
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>Medical Certificate</Title>
<Text fz="sm" c="dimmed">
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
</Text>
</div>
{/* Validity alert */}
{current && days <= 90 && days > 0 && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
Please visit an EMA-approved medical centre and upload your renewed certificate below.
</Alert>
)}
{current && days <= 0 && (
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
</Alert>
)}
{!current && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Current certificate */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="red" size={36} radius="md">
<IconHeart size={18} />
</ThemeIcon>
<Text fw={700}>Current Certificate</Text>
</Group>
{current ? (
<Stack gap="sm">
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
<Text fz="sm">{current.id}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
{formatDate(current.expiryDate)}
</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
<Text fz="sm">{current.restrictions}</Text>
</Group>
{/* Validity bar */}
<Box mt="xs">
<Group justify="space-between" mb={4}>
<Text fz="xs" c="dimmed">Validity remaining</Text>
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
</Group>
<Progress
value={progressVal}
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
radius="xl"
size="sm"
/>
</Box>
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={13} />}
mt="xs"
>
Download Certificate
</Button>
</Stack>
) : (
<Box ta="center" py="xl">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconFileDescription size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No certificate on record</Text>
</Box>
)}
</Paper>
{/* Upload new certificate */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconUpload size={18} />
</ThemeIcon>
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
</Group>
<Stack gap="sm">
<TextInput
label="Issuing Doctor / Medical Centre"
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
value={doctorName}
onChange={(e) => setDoctorName(e.currentTarget.value)}
size="sm"
/>
<SimpleGrid cols={2} spacing="sm">
<TextInput
label="Issue Date"
type="date"
value={issuedDate}
onChange={(e) => setIssuedDate(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Expiry Date"
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.currentTarget.value)}
size="sm"
/>
</SimpleGrid>
<div>
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
{uploadedFile ? (
<Card withBorder radius="sm" p="xs">
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
</Card>
) : (
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File (PDF / JPG / PNG, max 5MB)
</Button>
)}
</FileButton>
)}
</div>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
Notifications will be sent by email and SMS.
</Text>
</Alert>
<Button
leftSection={<IconCheck size={15} />}
onClick={handleSubmit}
loading={submitting}
disabled={!uploadedFile || !issuedDate || !expiryDate}
>
Submit for Verification
</Button>
</Stack>
</Paper>
</SimpleGrid>
{/* Notification schedule */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconCalendar size={18} />
</ThemeIcon>
<Text fw={700}>Expiry Notification Schedule</Text>
</Group>
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
<Text fz="xs" c="dimmed">First reminder time to book your medical examination</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
<Text fz="xs" c="dimmed">Second reminder urgent renewal required</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
<Text fz="xs" c="dimmed">Final reminder certificate expires very soon</Text>
</Timeline.Item>
</Timeline>
</Paper>
{/* History */}
{history.length > 0 && (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs">
{history.map((cert) => (
<Card key={cert.id} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="wrap" gap="xs">
<Group gap="sm">
<ThemeIcon variant="light" color="gray" size={32} radius="md">
<IconFileDescription size={16} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>{cert.id}</Text>
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} {formatDate(cert.expiryDate)}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Group>
</Card>
))}
</Stack>
</Paper>
)}
</Stack>
);
}

View File

@@ -4,16 +4,15 @@ import {
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
SegmentedControl,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconBellOff, IconCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useLocalized,
useGetNotificationsQuery,
@@ -21,15 +20,10 @@ import {
useMarkNotificationReadMutation,
} from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import { PageLoader } from '@ema-platform/ui';
type Tab = 'all' | 'unseen' | 'seen';
const EMPTY_COPY: Record<Tab, string> = {
all: 'No notifications yet.',
unseen: 'Nothing unread.',
seen: 'No read notifications.',
};
/**
* The applicant's notification inbox.
*
@@ -37,10 +31,16 @@ const EMPTY_COPY: Record<Tab, string> = {
* every transition — this previously listed a fixed array of invented alerts.
*/
export function NotificationsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [tab, setTab] = useState<Tab>('all');
const EMPTY_COPY: Record<Tab, string> = {
all: t('notifications.empty.all'),
unseen: t('notifications.empty.unseen'),
seen: t('notifications.empty.seen'),
};
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
const [markRead] = useMarkNotificationReadMutation();
@@ -57,9 +57,9 @@ export function NotificationsPage() {
return (
<Container size="md" py="md">
<Title order={3}>Notifications</Title>
<Title order={3}>{t('notifications.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
{unread > 0 ? t('notifications.unread', { count: unread }) : t('notifications.allCaughtUp')}
</Text>
<SegmentedControl
@@ -68,23 +68,21 @@ export function NotificationsPage() {
value={tab}
onChange={(v) => setTab(v as Tab)}
data={[
{ label: 'All', value: 'all' },
{ label: 'Unseen', value: 'unseen' },
{ label: 'Seen', value: 'seen' },
{ label: t('notifications.tabs.all'), value: 'all' },
{ label: t('notifications.tabs.unseen'), value: 'unseen' },
{ label: t('notifications.tabs.seen'), value: 'seen' },
]}
/>
{isLoading ? (
<Center h={300}>
<Loader />
</Center>
<PageLoader label={t('notifications.loading')} height={350} />
) : items.length === 0 ? (
<Card withBorder padding="xl">
<Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{EMPTY_COPY[tab]}</Text>
<Text size="sm" c="dimmed">
You will be notified as your applications progress.
{t('notifications.emptyBody')}
</Text>
</Stack>
</Card>
@@ -112,7 +110,7 @@ export function NotificationsPage() {
</Text>
{!n.isSeen && (
<Badge size="xs" variant="light">
new
{t('notifications.new')}
</Badge>
)}
</Group>
@@ -133,7 +131,7 @@ export function NotificationsPage() {
markRead(n.id);
}}
>
Mark read
{t('notifications.markRead')}
</Button>
)}
</Group>

View File

@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom';
import { Center, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
/**
* Sends an applicant who has not said what they operate as to the step that
@@ -25,6 +26,8 @@ const ALWAYS_ALLOWED = [
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
'/seafarer-registration',
'/seafarer/records',
'/seafarer/sea-service',
'/seafarer/medical',
'/seafarer-registry',
'/exams',
'/certificates',
@@ -64,6 +67,7 @@ function isModeFreeLicensingRoute(pathname: string): boolean {
}
export function RequireOperations({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
@@ -75,11 +79,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
}
if (isLoading) {
return (
<Center h={200}>
<Loader />
</Center>
);
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
// A failed lookup must not lock anyone out of the portal — the server still
@@ -93,11 +93,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
// the same tick; deciding on the pre-save cache bounced the applicant
// straight back to the screen they had just completed.
if (isFetching) {
return (
<Center h={200}>
<Loader />
</Center>
);
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
return <Navigate to="/onboarding/operations" replace />;
}

View File

@@ -1,24 +1,30 @@
import { useNavigate } from 'react-router-dom';
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
import { useNavigate } from "react-router-dom";
import { Container, Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OperationsFormContent } from "../../profile/components/OperationsFormContent";
/**
* Where a fresh applicant lands after declaring themselves. Someone who says
* "I own a vessel" came here to register, so they are taken straight to that
* form instead of a dashboard that only links to it. A seafarer goes to
* `/profile` instead — registration is built from the profile
* "I own a vessel" came here to register, so they are taken to that service's
* own page — which lists what they already hold and starts the application —
* rather than dropped straight into the wizard with no way back. A seafarer
* goes to `/profile` instead — registration is built from the profile
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so
* sending them straight to the wizard would only bounce them back here.
* Seafarer wins when both are ticked; the other form is one nav click away.
* Seafarer wins when both are ticked; the other page is one nav click away.
*
* Seafarer goes to its own registration page, whose Identity Details step
* collects the profile answers itself — no detour via `/profile`. Seafarer
* wins when both are ticked; the other form is one nav click away.
*/
const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/profile',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
SEAFARER_REGISTRATION: "/seafarer-registration",
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
};
function nextStepFor(selectedKeys: string[]): string {
const key = Object.keys(NEXT_STEP).find((k) => selectedKeys.includes(k));
return key ? NEXT_STEP[key] : '/dashboard';
return key ? NEXT_STEP[key] : "/dashboard";
}
/**
@@ -33,17 +39,16 @@ function nextStepFor(selectedKeys: string[]): string {
* soon as the profile has at least one mode.
*/
export function OperationsOnboardingPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Container size="sm" py="xl">
<Stack gap="lg">
<div>
<Title order={3}>What do you operate as?</Title>
<Title order={3}>{t("onboarding.operations.title")}</Title>
<Text size="sm" c="dimmed" mt={4}>
The Authority licenses by mode of operation. Tell us what your
company does and we will show you the licences you can apply for
you can change this later from your profile.
{t("onboarding.operations.body")}
</Text>
</div>
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -2,7 +2,9 @@ import { useState } from 'react';
import { notifications } from '@mantine/notifications';
import {
extractErrorMessage,
useInitiateDocumentPaymentMutation,
useInitiatePaymentMutation,
type InitiatePaymentResult,
} from '@ema-platform/api';
/**
@@ -15,21 +17,31 @@ import {
*/
export function useApplicationPayment() {
const [initiate, { isLoading }] = useInitiatePaymentMutation();
const [initiateDocument, { isLoading: isLoadingDocument }] =
useInitiateDocumentPaymentMutation();
const [redirecting, setRedirecting] = useState(false);
async function pay(
applicationId: string,
provider = 'TELEBIRR',
): Promise<void> {
const platform = () =>
// Deep links only work inside a mobile browser; assume web otherwise.
/Android|iPhone|iPad/i.test(navigator.userAgent) ? 'mobile' : 'web';
/** A licence application's fee. */
function pay(applicationId: string, provider = 'TELEBIRR'): Promise<void> {
return handOver(() =>
initiate({ id: applicationId, provider, platform: platform() }).unwrap(),
);
}
/** A Seaman Book / BTC fee — same gateway, its own endpoint. */
function payDocument(documentId: string, provider = 'TELEBIRR'): Promise<void> {
return handOver(() =>
initiateDocument({ id: documentId, provider, platform: platform() }).unwrap(),
);
}
async function handOver(start: () => Promise<InitiatePaymentResult>): Promise<void> {
try {
const result = await initiate({
id: applicationId,
provider,
// Deep links only work inside a mobile browser; assume web otherwise.
platform: /Android|iPhone|iPad/i.test(navigator.userAgent)
? 'mobile'
: 'web',
}).unwrap();
const result = await start();
const action = result.clientAction;
@@ -66,5 +78,5 @@ export function useApplicationPayment() {
}
}
return { pay, isPaying: isLoading || redirecting };
return { pay, payDocument, isPaying: isLoading || isLoadingDocument || redirecting };
}

View File

@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Center,
Container,
Group,
Loader,
@@ -17,7 +16,8 @@ import {
IconCircleCheck,
IconClockHour4,
} from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
const POLL_INTERVAL_MS = 3000;
const MAX_ATTEMPTS = 10;
@@ -31,36 +31,43 @@ const MAX_ATTEMPTS = 10;
* their account is the worst possible outcome here.
*/
export function PaymentCheckPage() {
const { t } = useTranslation();
const [params] = useSearchParams();
const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? '';
// Seaman Book / BTC fees come back with `documentId` instead.
const documentId = params.get('documentId') ?? '';
const [attempts, setAttempts] = useState(0);
const { data, refetch, isLoading } = useGetApplicationPaymentQuery(
applicationId,
{ skip: !applicationId },
);
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
skip: !applicationId,
});
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
const { data, refetch, isLoading } = documentId ? documentPayment : applicationPayment;
const subject = documentId ? `documentId=${documentId}` : `applicationId=${applicationId}`;
const hasSubject = Boolean(applicationId || documentId);
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
const status = data?.status ?? null;
const settled = status === 'PAID' || status === 'FAILED' || status === 'CANCELLED';
useEffect(() => {
if (!applicationId || settled || attempts >= MAX_ATTEMPTS) return;
if (!hasSubject || settled || attempts >= MAX_ATTEMPTS) return;
const timer = setTimeout(() => {
refetch();
setAttempts((n) => n + 1);
}, POLL_INTERVAL_MS);
return () => clearTimeout(timer);
}, [applicationId, settled, attempts, refetch]);
}, [hasSubject, settled, attempts, refetch]);
useEffect(() => {
if (status === 'PAID') navigate(`/payments/success?applicationId=${applicationId}`);
if (status === 'PAID') navigate(`/payments/success?${subject}`);
if (status === 'FAILED' || status === 'CANCELLED') {
navigate(`/payments/failure?applicationId=${applicationId}`);
navigate(`/payments/failure?${subject}`);
}
}, [status, applicationId, navigate]);
}, [status, subject, navigate]);
if (!applicationId) {
if (!hasSubject) {
return (
<Container size="sm" py="xl">
<Card withBorder padding="xl">
@@ -68,12 +75,12 @@ export function PaymentCheckPage() {
<ThemeIcon size={48} radius="xl" color="orange" variant="light">
<IconAlertTriangle size={24} />
</ThemeIcon>
<Title order={4}>We could not identify this payment</Title>
<Title order={4}>{t('payments.check.notFoundTitle')}</Title>
<Text size="sm" c="dimmed" ta="center">
Open the application from your list to check its payment status.
{t('payments.check.notFoundBody')}
</Text>
<Button onClick={() => navigate('/licensing/applications')}>
My applications
<Button onClick={() => navigate(backTo)}>
{t('payments.myApplications')}
</Button>
</Stack>
</Card>
@@ -92,18 +99,16 @@ export function PaymentCheckPage() {
<ThemeIcon size={48} radius="xl" color="yellow" variant="light">
<IconClockHour4 size={24} />
</ThemeIcon>
<Title order={4}>Still confirming your payment</Title>
<Title order={4}>{t('payments.check.stillConfirmingTitle')}</Title>
<Text size="sm" c="dimmed" ta="center">
Telebirr has not confirmed this payment yet. If the money has
left your account it will be applied automatically there is no
need to pay again.
{t('payments.check.stillConfirmingBody')}
</Text>
<Group>
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
Check again
{t('payments.check.checkAgain')}
</Button>
<Button onClick={() => navigate('/licensing/applications')}>
My applications
<Button onClick={() => navigate(backTo)}>
{t('payments.myApplications')}
</Button>
</Group>
</>
@@ -114,9 +119,9 @@ export function PaymentCheckPage() {
<IconCircleCheck size={24} />
</ThemeIcon>
)}
<Title order={4}>Confirming your payment</Title>
<Title order={4}>{t('payments.check.confirmingTitle')}</Title>
<Text size="sm" c="dimmed" ta="center">
This usually takes a few seconds. Please do not close this page.
{t('payments.check.confirmingBody')}
</Text>
</>
)}

View File

@@ -10,16 +10,23 @@ import {
Title,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
/** Shown when Telebirr reported the payment as failed or cancelled. */
export function PaymentFailurePage() {
const { t } = useTranslation();
const [params] = useSearchParams();
const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? '';
const { data } = useGetApplicationPaymentQuery(applicationId, {
// Seaman Book / BTC fees come back with `documentId` instead.
const documentId = params.get('documentId') ?? '';
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
skip: !applicationId,
});
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
const { data } = documentId ? documentPayment : applicationPayment;
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
return (
<Container size="sm" py="xl">
@@ -28,18 +35,16 @@ export function PaymentFailurePage() {
<ThemeIcon size={56} radius="xl" color="red" variant="light">
<IconAlertTriangle size={30} />
</ThemeIcon>
<Title order={3}>Payment not completed</Title>
<Title order={3}>{t('payments.failure.title')}</Title>
<Text size="sm" c="dimmed" ta="center">
{data?.failureReason
? data.failureReason
: 'The payment was not completed. Nothing has been charged.'}
{data?.failureReason ? data.failureReason : t('payments.failure.defaultReason')}
</Text>
<Text size="xs" c="dimmed" ta="center">
Your application is unchanged and you can try again at any time.
{t('payments.failure.unchanged')}
</Text>
<Group mt="md">
<Button variant="default" onClick={() => navigate('/licensing/applications')}>
My applications
<Button variant="default" onClick={() => navigate(backTo)}>
{t('payments.myApplications')}
</Button>
</Group>
</Stack>

View File

@@ -11,18 +11,25 @@ import {
Title,
} from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
/** Confirmation that the licence fee has been received. */
export function PaymentSuccessPage() {
const { t } = useTranslation();
const [params] = useSearchParams();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const applicationId = params.get('applicationId') ?? '';
const { data } = useGetApplicationPaymentQuery(applicationId, {
// Seaman Book / BTC fees come back with `documentId` instead.
const documentId = params.get('documentId') ?? '';
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
skip: !applicationId,
});
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
const { data } = documentId ? documentPayment : applicationPayment;
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
return (
<Container size="sm" py="xl">
@@ -31,10 +38,9 @@ export function PaymentSuccessPage() {
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCircleCheck size={30} />
</ThemeIcon>
<Title order={3}>Payment received</Title>
<Title order={3}>{t('payments.success.title')}</Title>
<Text size="sm" c="dimmed" ta="center">
Thank you. Your licence fee has been paid and your application is
being finalised. You will be notified when your certificate is ready.
{t('payments.success.body')}
</Text>
{data && (
@@ -42,24 +48,24 @@ export function PaymentSuccessPage() {
<Divider my="xs" w="100%" />
<Stack gap={4} w="100%">
<Group justify="space-between">
<Text size="sm" c="dimmed">Amount</Text>
<Text size="sm" c="dimmed">{t('payments.fields.amount')}</Text>
<Text size="sm" fw={600}>
{Number(data.amount).toLocaleString()} {data.currency}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Method</Text>
<Text size="sm" c="dimmed">{t('payments.fields.method')}</Text>
<Text size="sm">{data.provider}</Text>
</Group>
{data.providerRef && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Reference</Text>
<Text size="sm" c="dimmed">{t('payments.fields.reference')}</Text>
<Text size="sm" ff="monospace">{data.providerRef}</Text>
</Group>
)}
{data.paidAt && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Paid</Text>
<Text size="sm" c="dimmed">{t('payments.fields.paid')}</Text>
<Text size="sm">{showDate(data.paidAt)}</Text>
</Group>
)}
@@ -67,8 +73,8 @@ export function PaymentSuccessPage() {
</>
)}
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
Back to my applications
<Button mt="md" onClick={() => navigate(backTo)}>
{t('payments.success.backToApplications')}
</Button>
</Stack>
</Card>

View File

@@ -2,43 +2,51 @@ import { useCallback, useMemo } from 'react';
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location, LocationType } from '../../location/types/location';
export const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
idNumber: z.string().trim().min(1, 'Enter ID number'),
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
nationality: z.string().min(1, 'Select nationality'),
primaryPhoneNumber: ethiopianPhone,
secondaryPhoneNumber: optionalEthiopianPhone,
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subCityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().trim().optional(),
postalAddress: z.string().trim().optional(),
// Emergency contact is collected but never required — leaving it blank must
// not stop an applicant moving on.
emergencyContactName: z.string().trim().optional(),
emergencyContactPhone: optionalEthiopianPhone,
emergencyContactRelation: z.string().trim().optional(),
});
// Zod schemas can't call hooks, so the schema is built from `t` by the
// caller (ProfilePage) rather than defined once at module scope.
export function addressSchema(t: TFunction) {
return z.object({
idType: z.string().min(1, t('profileAddress.validation.idTypeRequired')),
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
primaryPhoneNumber: ethiopianPhone,
secondaryPhoneNumber: optionalEthiopianPhone,
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subCityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().trim().optional(),
postalAddress: z.string().trim().optional(),
// Emergency contact is collected but never required — leaving it blank must
// not stop an applicant moving on.
emergencyContactName: z.string().trim().optional(),
emergencyContactPhone: optionalEthiopianPhone,
emergencyContactRelation: z.string().trim().optional(),
});
}
export type AddressValues = z.infer<typeof addressSchema>;
export type AddressValues = z.infer<ReturnType<typeof addressSchema>>;
// Backend rejects anything outside this set:
// "idType must be one of the following values: NID, VITAL, PASSPORT, DRIVERS_LICENSE"
export const ID_TYPES = [
{ value: 'NID', label: 'National Id' },
{ value: 'VITAL', label: 'Vital ID' },
{ value: 'PASSPORT', label: 'Passport' },
{ value: 'DRIVERS_LICENSE', label: "Driver's License" },
] as const;
function idTypeOptions(t: TFunction) {
return [
{ value: 'NID', label: t('profileAddress.idTypeOptions.NID') },
{ value: 'VITAL', label: t('profileAddress.idTypeOptions.VITAL') },
{ value: 'PASSPORT', label: t('profileAddress.idTypeOptions.PASSPORT') },
{ value: 'DRIVERS_LICENSE', label: t('profileAddress.idTypeOptions.DRIVERS_LICENSE') },
] as const;
}
/**
* Location types are data-driven rows (no fixed depth), so the chain is
@@ -79,6 +87,7 @@ export function AddressFormContent({
watch,
trigger,
}: AddressFormContentProps) {
const { t } = useTranslation();
const { data: typesRes } = useGetLocationTypesQuery();
const locationTypes = typesRes?.items;
@@ -113,10 +122,10 @@ export function AddressFormContent({
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="ID Type"
placeholder="Select"
label={t('profileFields.idType')}
placeholder={t('profileAddress.idTypePlaceholder')}
required
data={ID_TYPES}
data={idTypeOptions(t)}
error={errors.idType?.message}
value={watch('idType')}
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
@@ -124,14 +133,14 @@ export function AddressFormContent({
name="idType"
/>
<TextInput
label="ID Number"
placeholder="Enter ID number"
label={t('profileFields.idNumber')}
placeholder={t('profileAddress.idNumberPlaceholder')}
required
{...register('idNumber')}
error={errors.idNumber?.message}
/>
<CountrySelect
label="Nationality"
label={t('profileFields.nationality')}
demonym
required
value={watch('nationality') || null}
@@ -139,23 +148,23 @@ export function AddressFormContent({
error={errors.nationality?.message}
/>
<TextInput
label="Primary Phone"
description="From your account, edit it in the Personal tab"
label={t('profileFields.primaryPhoneNumber')}
description={t('profileAddress.accountManagedHint')}
required
readOnly
{...register('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<TextInput
label="Secondary Phone"
placeholder="+251 9XX XXX XXX"
label={t('profileAddress.secondaryPhoneNumber')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
label="Email"
label={t('profileFields.email')}
type="email"
description="From your account, edit it in the Personal tab"
description={t('profileAddress.accountManagedHint')}
readOnly
{...register('email')}
error={errors.email?.message}
@@ -163,44 +172,49 @@ export function AddressFormContent({
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
{t('profileAddress.addressSection')}
</Text>
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
{/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded
level but nothing collects it, so `kebeleId` stays unset rather than
borrowing the woreda's id. */}
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput
label="Street Address"
placeholder="Street name, house number"
label={t('profileFields.streetAddress')}
placeholder={t('profileAddress.streetAddressPlaceholder')}
{...register('streetAddress')}
error={errors.streetAddress?.message}
/>
<TextInput
label="Postal Address"
placeholder="P.O. Box"
label={t('profileAddress.postalAddress')}
placeholder={t('profileAddress.postalAddressPlaceholder')}
{...register('postalAddress')}
error={errors.postalAddress?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact <Text span c="dimmed" fz="xs" tt="none" fw={400}>(optional)</Text>
{t('profileAddress.emergencyContactSection')}{' '}
<Text span c="dimmed" fz="xs" tt="none" fw={400}>
{t('profileAddress.emergencyContactOptional')}
</Text>
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Contact Name"
placeholder="Full name"
label={t('profileAddress.contactName')}
placeholder={t('profileAddress.contactNamePlaceholder')}
{...register('emergencyContactName')}
error={errors.emergencyContactName?.message}
/>
<TextInput
label="Contact Phone"
placeholder="+251 9XX XXX XXX"
label={t('profileAddress.contactPhone')}
placeholder={t('profileAddress.phonePlaceholder')}
{...register('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput
label="Relationship"
placeholder="Spouse, Parent, etc."
label={t('profileAddress.relationship')}
placeholder={t('profileAddress.relationshipPlaceholder')}
{...register('emergencyContactRelation')}
error={errors.emergencyContactRelation?.message}
/>

View File

@@ -12,6 +12,7 @@ import {
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
useLocalized,
@@ -19,6 +20,7 @@ import {
useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api';
import { useUpdateMyAccountTypeMutation } from '@ema-platform/auth';
import { notify, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
@@ -34,6 +36,29 @@ const PERSONAL_REGISTRATION_KEYS = [
'VESSEL_REGISTRATION',
];
/**
* Declared modes -> portal account type.
*
* The account type is what the server computes portal permissions from, and
* therefore what decides which services appear in the sidebar at all. Modes
* of operation grant nothing on their own: an applicant who declared
* "freight forwarder" here was still left on the seafarer default and never
* saw the logistics or vessel entries.
*
* One type per account against a multi-select, so the widest capability wins:
* a company representative who also sails still needs the company services,
* and the seafarer side stays reachable through their own registration.
*/
function accountTypeFor(keys: string[]): string | null {
if (keys.some((key) => !PERSONAL_REGISTRATION_KEYS.includes(key))) {
// Historical enum value for the logistics company representative.
return 'FRIGHTER';
}
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
return null;
}
/**
* The applicant's modes of operation — what they do, and therefore which
* licences the portal offers them.
@@ -53,9 +78,11 @@ export function OperationsFormContent({
*/
onSaved?: (selectedKeys: string[]) => void;
} = {}) {
const { t } = useTranslation();
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
const [setAccountType] = useUpdateMyAccountTypeMutation();
const localized = useLocalized();
const declaredIds = useMemo(
@@ -116,21 +143,32 @@ export function OperationsFormContent({
async function persist() {
try {
await save({ licenseTypeIds: selected }).unwrap();
const selectedKeys = options
.filter((t) => selected.includes(t.id))
.map((t) => t.key);
const type = accountTypeFor(selectedKeys);
if (type) {
// Refused for a registered seafarer, whose type the authority owns
// (`registered_seafarer_type_is_fixed`), and for staff. The modes are
// already stored either way, so a refusal here is not the applicant's
// problem to see.
await setAccountType({ type }).unwrap().catch(() => undefined);
}
setConfirmingRemoval(false);
notify.success(
'The licences you can apply for have been updated to match.',
'Operations updated',
);
onSaved?.(
options.filter((t) => selected.includes(t.id)).map((t) => t.key),
t('profileOperations.updateSuccessBody'),
t('profileOperations.updateSuccessTitle'),
);
onSaved?.(selectedKeys);
} catch (err) {
notify.error(extractErrorMessage(err), 'Could not save');
notify.error(extractErrorMessage(err), t('profileOperations.updateErrorTitle'));
}
}
if (loadingTypes || loadingMine) {
return <Loader size="sm" />;
return <Loader size="sm" type="oval" />;
}
const removedNames = options
@@ -140,10 +178,9 @@ export function OperationsFormContent({
return (
<Stack gap="xl">
<div>
<Title order={5}>Mode of operation</Title>
<Title order={5}>{t('profileOperations.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
What your company operates as. This decides which licences you are
offered you can change it whenever your business changes.
{t('profileOperations.description')}
</Text>
<Checkbox.Group value={selected} onChange={setSelected}>
@@ -157,7 +194,7 @@ export function OperationsFormContent({
<Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal">
Current
{t('profileOperations.current')}
</Badge>
)}
</Group>
@@ -203,8 +240,7 @@ export function OperationsFormContent({
{options.length === 0 && (
<Text size="sm" c="dimmed">
No licence types are configured yet. Contact EMA if you were
expecting one.
{t('profileOperations.emptyState')}
</Text>
)}
</div>
@@ -214,16 +250,17 @@ export function OperationsFormContent({
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
title="No operations selected"
title={t('profileOperations.noneSelectedTitle')}
>
With none selected you will not be offered any licence to apply for.
Existing applications and issued licences are unaffected.
{t('profileOperations.noneSelectedBody')}
</Alert>
)}
<Group justify="space-between">
<Text size="xs" c="dimmed">
{lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'}
{lastChanged
? t('profileOperations.lastChanged', { date: showDate(lastChanged) })
: t('profileOperations.notSetYet')}
</Text>
<Group gap="sm">
{dirty && (
@@ -232,7 +269,7 @@ export function OperationsFormContent({
size="sm"
onClick={() => setSelected(declaredIds)}
>
Discard changes
{t('profileOperations.discardChanges')}
</Button>
)}
<Button
@@ -244,7 +281,7 @@ export function OperationsFormContent({
removed.length > 0 ? setConfirmingRemoval(true) : persist()
}
>
Save operations
{t('profileOperations.saveOperations')}
</Button>
</Group>
</Group>
@@ -254,31 +291,29 @@ export function OperationsFormContent({
<Modal
opened={confirmingRemoval}
onClose={() => setConfirmingRemoval(false)}
title="Remove from your operations?"
title={t('profileOperations.removeModalTitle')}
centered
>
<Stack gap="md">
<Text size="sm">
You are removing{' '}
{t('profileOperations.removingPrefix')}{' '}
<Text span fw={600}>
{removedNames.join(', ')}
</Text>
.
</Text>
<Text size="sm" c="dimmed">
You will no longer be offered a new application of that type.
Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed.
{t('profileOperations.removeConsequence')}
</Text>
<ModalFooter gap="sm">
<Button
variant="default"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
{t('common.cancel')}
</Button>
<Button color="orange" loading={saving} onClick={persist}>
Remove and save
{t('profileOperations.removeAndSave')}
</Button>
</ModalFooter>
</Stack>

View File

@@ -1,20 +1,38 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { AmharicDatePicker } from '@ema-platform/ui';
export const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'),
firstName: z.string().min(3, 'First name must be at least 3 characters'),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
gender: z.string().min(1, 'Select your gender'),
dob: z.string().min(1, 'Select your date of birth'),
pob: z.string().optional(),
maritalStatus: z.string().min(1, 'Select your marital status'),
});
// dob comes in as a plain yyyy-MM-dd string; skip on empty/malformed so
// dobRequired's own message takes precedence.
function isAtLeast18(dob: string): boolean {
const birth = new Date(dob);
if (Number.isNaN(birth.getTime())) return true;
const today = new Date();
const cutoff = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
return birth <= cutoff;
}
export type ProfileValues = z.infer<typeof profileSchema>;
export const profileSchema = (t: TFunction) =>
z.object({
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
gender: z.string().min(1, t('profileForm.validation.genderRequired')),
dob: z
.string()
.min(1, t('profileForm.validation.dobRequired'))
.refine((value) => isAtLeast18(value), {
message: t('profileForm.validation.dobMinAge'),
}),
pob: z.string().optional(),
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
});
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
@@ -39,11 +57,13 @@ export function ProfileFormContent({
professionsLoading,
professionOptions,
}: ProfileFormContentProps) {
const { t } = useTranslation();
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="Profession"
placeholder={professionsLoading ? 'Loading...' : 'Select'}
label={t('profileFields.professionId')}
placeholder={professionsLoading ? t('common.loading') : t('common.select')}
required
data={professionOptions}
error={errors.professionId?.message}
@@ -53,34 +73,34 @@ export function ProfileFormContent({
name="professionId"
searchable
disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
rightSection={professionsLoading ? <Loader size="xs" type="oval" /> : undefined}
/>
<TextInput
label="First Name"
placeholder="Enter first name"
label={t('profileFields.firstName')}
placeholder={t('profileForm.placeholders.firstName')}
required
{...register('firstName')}
error={errors.firstName?.message}
/>
<TextInput
label="Middle Name"
placeholder="Enter middle name"
label={t('profileFields.middleName')}
placeholder={t('profileForm.placeholders.middleName')}
required
{...register('middleName')}
error={errors.middleName?.message}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
label={t('profileFields.lastName')}
placeholder={t('profileForm.placeholders.lastName')}
required
{...register('lastName')}
error={errors.lastName?.message}
/>
<Select
label="Gender"
placeholder="Select"
label={t('profileFields.gender')}
placeholder={t('common.select')}
required
data={[...GENDERS]}
data={GENDERS.map((g) => ({ value: g, label: t(`profileForm.genders.${g}`) }))}
error={errors.gender?.message}
value={watch('gender')}
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
@@ -88,7 +108,7 @@ export function ProfileFormContent({
name="gender"
/>
<AmharicDatePicker
label="Date of Birth"
label={t('profileFields.dob')}
required
value={watch('dob')}
onChange={(val) => setValue('dob', val, { shouldValidate: true })}
@@ -97,16 +117,16 @@ export function ProfileFormContent({
error={errors.dob?.message}
/>
<TextInput
label="Place of Birth"
placeholder="City, Region"
label={t('profileFields.pob')}
placeholder={t('profileForm.placeholders.pob')}
{...register('pob')}
error={errors.pob?.message}
/>
<Select
label="Marital Status"
placeholder="Select"
label={t('profileFields.maritalStatus')}
placeholder={t('common.select')}
required
data={[...MARITAL_STATUSES]}
data={MARITAL_STATUSES.map((m) => ({ value: m, label: t(`profileForm.maritalStatuses.${m}`) }))}
error={errors.maritalStatus?.message}
value={watch('maritalStatus')}
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}

View File

@@ -59,7 +59,7 @@ export function ProfileRequirementGate({
})}
>
<Stack gap="xs">
<Text size="sm">{requirement.reason}</Text>
<Text size="sm">{t(requirement.reason)}</Text>
<List size="sm" spacing={2}>
{gaps.map((field) => (
<List.Item key={field}>

View File

@@ -1,105 +1,27 @@
import { useEffect } from 'react';
import { Center, Loader } from '@mantine/core';
import { Navigate, useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import {
PROFILE_FIELD_SECTION,
useCurrentProfile,
type ProfileRequirement,
} from '@ema-platform/auth';
import type { ProfileRequirement } from "@ema-platform/auth";
/**
* Seafarer registration is filled in from the profile (nationality, ID,
* names, contact details) — the server refuses an application missing them,
* so they're asked for up front instead of at submit time.
* The identity a seafarer registration is built from.
*
* Only the fields the Personal, Maritime Profile and Address tabs actually
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
* always satisfiable by finishing those tabs and never blocks on an optional
* field (place of birth, region/city/woreda, emergency contact) the forms
* don't star.
* Not a gate: the registration form (`/seafarer-registration`) collects these
* itself and prefills from the profile where it can. `ProfilePage` reads it
* to show what a registration will need.
*/
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
fields: [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
"firstName",
"middleName",
"lastName",
"gender",
"dob",
"maritalStatus",
"professionId",
"idType",
"idNumber",
"nationality",
"primaryPhoneNumber",
"email",
],
reason:
'Seafarer registration is built from your profile — these details fill it in for you.',
"Seafarer registration is built from your profile — these details fill it in for you.",
};
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
/**
* Sends an applicant with an incomplete profile to `/profile` before they can
* reach seafarer registration. Wraps `/seafarer-registration` directly and
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
* latter is the shared wizard route every licence type renders through, so
* without it the gate is a decoration a deep link skips.
*
* Fires before the wizard starts, not mid-application, so nothing is lost —
* unlike the case `ProfileRequirementGate`'s doc comment warns against
* (mid-flow redirects on the old, deleted setup wizard).
*/
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { typeCode } = useParams();
const { pathname } = useLocation();
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
// Shared wizard route — only the seafarer type is gated here.
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
useEffect(() => {
if (!redirecting) return;
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
notify.info(t('profileGate.seafarerRedirect', { fields }));
// Fire once per redirect, not on every render while gaps/gapsFor are
// recreated — the toast content is captured at the moment it fires.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [redirecting, pathname]);
if (!gated) return <>{children}</>;
if (isLoading) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
// A failed lookup must not lock anyone out — the server still refuses the
// application for a profile it can't fill in from.
if (error) return <>{children}</>;
if (gaps.length === 0) return <>{children}</>;
// Gaps while a save is still landing are not an answer yet. Saving a
// profile tab invalidates this query and the applicant may already be
// headed back here in the same tick — deciding on the pre-save cache would
// bounce them off the screen they just finished.
if (isFetching) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
const target = PROFILE_FIELD_SECTION[gaps[0]];
return <Navigate to={`/profile#${target}`} replace />;
}

View File

@@ -51,7 +51,7 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api';
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import type { AuthUser } from '@ema-platform/auth';
@@ -129,7 +129,16 @@ export function ProfilePage() {
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
// Two-step verification is wired but parked for the testing phase: turning it
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
// to re-enable it (the login/OTP side already handles `mfaRequired`).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
// const {
// enabled: twoStepEnabled,
// isLoading: twoStepLoading,
// isSaving: twoStepSaving,
// setEnabled: setTwoStepEnabled,
// } = useTwoFactor();
const [emailNotifications, setEmailNotifications] = useState(true);
// ---- Profession list (for Profile tab) ----
@@ -224,7 +233,8 @@ export function ProfilePage() {
idType: currentProfile.address?.idType || '',
idNumber: currentProfile.address?.idNumber || '',
// Stored as a country name; the select works in alpha-2 codes.
nationality: getCountryCode(currentProfile.address?.nationality) || '',
// Default to Ethiopian when no nationality is on record yet.
nationality: getCountryCode(currentProfile.address?.nationality) || 'ET',
primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
email: user?.email || '',
@@ -255,7 +265,7 @@ export function ProfilePage() {
.string()
.refine(
(name) => Object.values(splitProfileName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' },
{ message: t('profileForm.validation.nameParts') },
),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
@@ -355,7 +365,7 @@ export function ProfilePage() {
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(profileSchema(t)),
values: loadedProfile ?? undefined,
});
@@ -364,7 +374,7 @@ export function ProfilePage() {
const fullName = formatProfileName(values);
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
notify.error('Profile name must match the name in the Personal tab.');
notify.error(t('profile.nameMismatch'));
return;
}
@@ -381,7 +391,7 @@ export function ProfilePage() {
// the address save below) — without this, `missing` stays stale until
// a reload.
refetchProfile();
notify.success('Profile updated');
notify.success(t('profile.profileUpdated'));
} catch (e) {
handleError(e);
} finally {
@@ -398,7 +408,7 @@ export function ProfilePage() {
trigger: addressTriggerValidation,
formState: { errors: addressErrors },
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
resolver: zodResolver(addressSchema(t)),
values: loadedAddress ?? undefined,
});
@@ -409,7 +419,7 @@ export function ProfilePage() {
profileId,
body: toAddressPayload(values),
}).unwrap();
notify.success('Address saved');
notify.success(t('profile.addressSaved'));
} catch (e) {
handleError(e);
}
@@ -589,19 +599,19 @@ export function ProfilePage() {
>
<Tabs.List>
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
Personal
{t('profile.tabs.personal')}
</Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
Profile
{t('profile.tabs.profile')}
</Tabs.Tab>
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address
{t('profile.tabs.address')}
</Tabs.Tab>
<Tabs.Tab
value="operations"
leftSection={<IconBuildingWarehouse size={18} />}
>
Operations
{t('profile.tabs.operations')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
@@ -696,15 +706,15 @@ export function ProfilePage() {
<Center py="xl"><Loader /></Center>
) : !loadedProfile ? (
<Text c="dimmed" ta="center" py="xl">
No profile found. Complete your profile setup first.
{t('profile.maritimeSection.noProfile')}
</Text>
) : (
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>Maritime Profile</Title>
<Title order={5}>{t('profile.maritimeSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
Your professional maritime details
{t('profile.maritimeSection.subtitle')}
</Text>
<ProfileFormContent
register={registerProfile}
@@ -723,7 +733,7 @@ export function ProfilePage() {
loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Profile
{t('profile.maritimeSection.save')}
</Button>
</Group>
</Stack>
@@ -741,9 +751,9 @@ export function ProfilePage() {
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
<Stack gap="xl">
<div>
<Title order={5}>Address & Contact</Title>
<Title order={5}>{t('profile.addressSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
Your identity documents, contact details and emergency contact
{t('profile.addressSection.subtitle')}
</Text>
<AddressFormContent
register={registerAddress}
@@ -778,8 +788,9 @@ export function ProfilePage() {
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="lg">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.security')}</Title>
@@ -854,6 +865,15 @@ export function ProfilePage() {
<Switch
checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
// disabled={twoStepLoading || twoStepSaving}
// onChange={async (e) => {
// try {
// await setTwoStepEnabled(e.currentTarget.checked);
// notify.success(t('profile.twoStep.saved'));
// } catch (err) {
// handleError(err);
// }
// }}
/>
</Group>
@@ -867,8 +887,11 @@ export function ProfilePage() {
</Button>
</Group>
</Stack>
</form>
</Paper>
</form>
</Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel>
{/* ---- Preferences ---- */}
@@ -897,7 +920,7 @@ export function ProfilePage() {
{t(`language.${lng}`)}
</Text>
<Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'}
{t(`profile.languageFull.${lng}`)}
</Text>
</div>
{active ? (

View File

@@ -11,8 +11,6 @@ export interface AddressPayload {
regionId?: string;
cityId?: string;
subCityId?: string;
/** Legacy spelling still accepted by the address upsert endpoint. */
subcityId?: string;
woredaId?: string;
kebeleId?: string;
streetAddress?: string;
@@ -28,15 +26,15 @@ export interface AddressPayload {
emergencyContactRelation?: string;
}
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */
/** Blank optional strings drop out. */
export function toAddressPayload(values: AddressValues): AddressPayload {
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
const regionId = clean(values.regionId);
// The location service uses the selected City for the profile's region.
// Send that same id as cityId too, because profile completeness requires
// both fields even when the location tree has no separate region node.
// The seeded location tree tops out at CITY — Addis Ababa is a city-state,
// so a selected city stands in for the region and both ids are the same
// node. Profile completeness requires both, and the picker only ever yields
// one of them.
const cityId = clean(values.cityId) ?? regionId;
const subCityId = clean(values.subCityId);
return {
idType: values.idType.trim(),
@@ -45,10 +43,12 @@ export function toAddressPayload(values: AddressValues): AddressPayload {
nationality: getCountryName(values.nationality),
regionId,
cityId,
subCityId,
subcityId: subCityId, // legacy spelling, same value
subCityId: clean(values.subCityId),
woredaId: clean(values.woredaId),
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
// Left unset rather than mirroring woredaId: the picker stops at woreda,
// and copying that id here filed a WOREDA-typed node in kebele_id, so the
// column could not be trusted to mean what it says.
kebeleId: clean(values.kebeleId),
streetAddress: clean(values.streetAddress),
primaryPhoneNumber: values.primaryPhoneNumber,
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),

View File

@@ -0,0 +1,140 @@
import { useRef, useState } from 'react';
import { Alert, Badge, Button, Card, FileButton, Group, Loader, Stack, Text } from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconFileUpload } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
uploadDocument,
type Attachment,
} from '@ema-platform/api';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
/** The document slots a registration asks for. */
export function documentSlots(passportDeclared: boolean) {
return SEAFARER_REGISTRATION_DOCUMENTS.map((d) => ({
...d,
isRequired: d.required === 'passport' ? passportDeclared : d.required,
})).filter((d) => d.required !== 'passport' || passportDeclared);
}
export function RegistrationDocuments({
registrationId,
passportDeclared,
attachments,
readOnly,
onUploaded,
}: {
registrationId: string;
passportDeclared: boolean;
attachments: Attachment[];
readOnly?: boolean;
onUploaded: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
async function handle(documentKey: string, file: File | null) {
if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
resetRefs.current[documentKey]?.();
return;
}
setBusy(documentKey);
setError(null);
const result = await uploadDocument({
ownerType: 'SEAFARER_REGISTRATION',
ownerId: registrationId,
documentKey,
file,
});
setBusy(null);
resetRefs.current[documentKey]?.();
if (result.ok) onUploaded();
else setError(result.error);
}
return (
<Stack gap="sm">
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{documentSlots(passportDeclared).map((slot) => {
const existing = attachments.find((a) => a.documentKey === slot.key);
const uploaded = Boolean(existing?.files?.length);
return (
<Card
key={slot.key}
withBorder
padding="md"
style={{
borderColor: uploaded ? 'var(--mantine-color-teal-4)' : undefined,
borderStyle: uploaded ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{slot.name}
</Text>
{!slot.isRequired && (
<Badge size="xs" variant="light" color="gray">
optional
</Badge>
)}
{uploaded && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
</Badge>
)}
</Group>
{slot.description && (
<Text size="xs" c="dimmed" mt={2}>
{slot.description}
</Text>
)}
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate mt={2}>
{existing.files[0].originalName} · {(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
<Button size="xs" variant="subtle" component="a" href={existing.files[0].url} target="_blank">
View
</Button>
)}
{!readOnly && (
<FileButton
resetRef={(r) => {
if (r) resetRefs.current[slot.key] = r;
}}
onChange={(file) => handle(slot.key, file)}
accept={slot.accept}
>
{(props) => (
<Button
{...props}
size="xs"
variant={uploaded ? 'light' : 'filled'}
leftSection={busy === slot.key ? <Loader size={12} /> : <IconFileUpload size={14} />}
disabled={busy === slot.key}
>
{uploaded ? 'Replace' : 'Upload'}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,87 @@
import { Divider, Stack, Table, Text } from '@mantine/core';
import {
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS,
displaySeafarerAnswer,
type Attachment,
type SaveSeafarerRegistration,
} from '@ema-platform/api';
import { documentSlots } from './RegistrationDocuments';
/** Read-only view of every answer and upload, grouped as the wizard asked them. */
export function RegistrationSummary({
answers,
attachments,
}: {
answers: SaveSeafarerRegistration;
attachments?: Attachment[];
}) {
return (
<Stack gap="md">
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
{section.title}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{section.fields
.filter((f) => f !== 'passportExpiry' || answers.passportNumber)
.map((field) => (
<Table.Tr key={field}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{SEAFARER_REGISTRATION_FIELD_LABELS[field]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
))}
{attachments && (
<>
<Divider />
<Text fw={600} size="sm" mb={4}>
Documents
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{documentSlots(Boolean(answers.passportNumber)).map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
return (
<Table.Tr key={slot.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{slot.name}
</Text>
</Table.Td>
<Table.Td>
{file ? (
file.url ? (
<a href={file.url} target="_blank" rel="noreferrer">
{file.originalName}
</a>
) : (
<Text size="sm">{file.originalName}</Text>
)
) : (
<Text size="sm" c={slot.isRequired ? 'red' : 'dimmed'}>
{slot.isRequired ? 'Missing' : '—'}
</Text>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</>
)}
</Stack>
);
}

View File

@@ -0,0 +1,143 @@
import { Checkbox, Grid, Input, NumberInput, Select, TextInput } from '@mantine/core';
import type { SaveSeafarerRegistration } from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, getCountryCode, getCountryName } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
export type AnswerKey = keyof SaveSeafarerRegistration;
/** What every step receives: the answers, a setter, the errors, and whether it is locked. */
export interface StepProps {
form: SaveSeafarerRegistration;
set: (key: AnswerKey, value: unknown) => void;
errors: Partial<Record<AnswerKey, string>>;
disabled?: boolean;
}
interface FieldProps extends StepProps {
name: AnswerKey;
label: string;
required?: boolean;
description?: string;
maxLength?: number;
span?: number;
}
const common = (p: FieldProps) => ({
label: p.label,
description: p.description,
withAsterisk: p.required,
error: p.errors[p.name],
disabled: p.disabled,
});
const Col = ({ span = 6, children }: { span?: number; children: React.ReactNode }) => (
<Grid.Col span={{ base: 12, md: span }}>{children}</Grid.Col>
);
export function TextField(p: FieldProps) {
return (
<Col span={p.span}>
<TextInput
{...common(p)}
maxLength={p.maxLength}
value={(p.form[p.name] as string) ?? ''}
onChange={(e) => p.set(p.name, e.currentTarget.value)}
/>
</Col>
);
}
export function SelectField(p: FieldProps & { options: { value: string; label: string }[] }) {
return (
<Col span={p.span}>
<Select
{...common(p)}
data={p.options}
value={(p.form[p.name] as string) ?? null}
onChange={(v) => p.set(p.name, v)}
clearable={!p.required}
/>
</Col>
);
}
export function DateField(p: FieldProps) {
return (
<Col span={p.span}>
<AmharicDatePicker
label={p.label}
error={p.errors[p.name]}
disabled={p.disabled}
required={p.required}
dateFormat="date"
value={(p.form[p.name] as string) ?? ''}
onChange={(v) => p.set(p.name, v)}
/>
</Col>
);
}
export function NumberField(p: FieldProps) {
return (
<Col span={p.span}>
<NumberInput
{...common(p)}
// Not clamped: validation reports an out-of-range figure instead of
// Mantine quietly rewriting what the applicant typed.
value={(p.form[p.name] as number) ?? ''}
onChange={(v) => p.set(p.name, v === '' ? null : Number(v))}
/>
</Col>
);
}
/** Stored as the full country name, like the profile; picked by alpha-2 code. */
export function NationalityField(p: FieldProps) {
const stored = (p.form[p.name] as string) ?? '';
return (
<Col span={p.span}>
<CountrySelect
{...common(p)}
required={p.required}
demonym
value={getCountryCode(stored) ?? (stored || null)}
onChange={(code) => p.set(p.name, code ? getCountryName(code) || code : null)}
/>
</Col>
);
}
export function LocationField(p: FieldProps) {
return (
<Col span={p.span}>
<Input.Wrapper
label={p.label}
description={p.description}
withAsterisk={p.required}
error={p.errors[p.name]}
>
<LocationPicker
value={(p.form[p.name] as string) ?? undefined}
onChange={(id) => p.set(p.name, id)}
required={p.required}
maxDepth={3}
disabled={p.disabled}
/>
</Input.Wrapper>
</Col>
);
}
export function CheckboxField(p: FieldProps) {
return (
<Col span={12}>
<Checkbox
label={p.label}
error={p.errors[p.name]}
disabled={p.disabled}
checked={Boolean(p.form[p.name])}
onChange={(e) => p.set(p.name, e.currentTarget.checked)}
/>
</Col>
);
}

View File

@@ -0,0 +1,173 @@
import { Divider, Grid, Stack, Text, TextInput } from '@mantine/core';
import {
BLOOD_TYPE_OPTIONS,
DEPARTMENT_OPTIONS,
EYE_COLOR_OPTIONS,
GENDER_OPTIONS,
HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS,
} from '@ema-platform/api';
import {
DateField,
LocationField,
NationalityField,
NumberField,
SelectField,
TextField,
type StepProps,
} from './fields';
function SectionTitle({ title, description }: { title: string; description?: string }) {
return (
<div>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" mt={2}>
{description}
</Text>
)}
</div>
);
}
/**
* Step 1 — Identity Details.
*
* Email and phone are account credentials: shown, never edited here. The
* rest is prefilled from the profile and editable — what is entered becomes
* the registered identity once a reviewer approves.
*/
export function IdentityDetailsStep(
p: StepProps & { account: { email?: string; phoneNumber?: string } },
) {
return (
<Stack gap="lg">
<SectionTitle title="Contact Details" />
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput label="Account Email" value={p.account.email ?? ''} disabled />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput label="Account Phone Number" value={p.account.phoneNumber ?? ''} disabled />
</Grid.Col>
</Grid>
<Divider />
<SectionTitle
title="Identity Details"
description="Prefilled from your profile where we have it. Check each field and correct anything that is wrong — what you enter here becomes your registered identity."
/>
<Grid>
<TextField {...p} name="firstName" label="First Name" required maxLength={128} />
<TextField {...p} name="middleName" label="Middle Name" maxLength={128} />
<TextField {...p} name="lastName" label="Last Name" required maxLength={128} />
<SelectField {...p} name="gender" label="Gender" required options={GENDER_OPTIONS} />
<DateField {...p} name="dateOfBirth" label="Date of Birth" required />
<SelectField {...p} name="maritalStatus" label="Marital Status" required options={MARITAL_STATUS_OPTIONS} />
<NationalityField {...p} name="nationality" label="Nationality" required />
<TextField {...p} name="nationalIdNumber" label="National ID (Fayda) Number" required maxLength={64} />
</Grid>
</Stack>
);
}
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
export function ApplicantDetailsStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Identity" />
<Grid>
<TextField {...p} name="placeOfBirth" label="Place of Birth" required maxLength={128} />
<TextField
{...p}
name="passportNumber"
label="Passport Number"
maxLength={32}
description="Required later for international sea service; optional at registration."
/>
{p.form.passportNumber && (
<DateField {...p} name="passportExpiry" label="Passport Expiry Date" />
)}
<SelectField
{...p}
name="department"
label="Department"
required
options={DEPARTMENT_OPTIONS}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/>
</Grid>
<Divider />
<SectionTitle title="Address" />
<Grid>
<LocationField
{...p}
name="locationId"
label="Location"
required
description="City / sub-city selected from the location picker."
/>
<TextField {...p} name="permanentAddress" label="Permanent Address" maxLength={255} />
<TextField
{...p}
name="currentAddress"
label="Current Address"
maxLength={255}
description="Where you currently live, if different from the permanent address."
/>
</Grid>
<Divider />
<SectionTitle
title="Physical Characteristics"
description="Identifying details printed in your Seaman Book."
/>
<Grid>
<SelectField {...p} name="hairColor" label="Hair Colour" required options={HAIR_COLOR_OPTIONS} />
<SelectField {...p} name="eyeColor" label="Eye Colour" required options={EYE_COLOR_OPTIONS} />
<NumberField {...p} name="heightCm" label="Height (cm)" required description="In centimetres, e.g. 172.5" />
<NumberField {...p} name="weightKg" label="Weight (kg)" required description="In kilograms, e.g. 68.0" />
<SelectField
{...p}
name="bloodType"
label="Blood Type"
options={BLOOD_TYPE_OPTIONS}
description="Optional. Select Unknown if you have not been tested."
/>
</Grid>
<Divider />
<SectionTitle
title="Medical Certificate"
description="Details from your STCW medical fitness certificate. The expiry date is calculated from the issue date."
/>
<Grid>
<TextField {...p} name="medicalCertificateNumber" label="Certificate Number" required maxLength={64} />
<TextField {...p} name="medicalIssuerName" label="Issuing Clinic or Practitioner" required maxLength={255} />
<DateField
{...p}
name="medicalIssueDate"
label="Issue Date"
required
description="Cannot be a future date. Validity is calculated from this: two years, or one year if you are under 18."
/>
</Grid>
</Stack>
);
}
/** Step 3 — Emergency Contact. */
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,424 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Center,
Container,
Divider,
Grid,
Group,
Loader,
Paper,
Stack,
Stepper,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconCheck, IconInfoCircle, IconPencil } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
useGetAttachmentsQuery,
useGetMySeafarerRegistrationQuery,
useSaveSeafarerRegistrationMutation,
useStartSeafarerRegistrationMutation,
useSubmitSeafarerRegistrationMutation,
type SaveSeafarerRegistration,
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName } from '@ema-platform/ui';
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields';
import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from '../components/steps';
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary';
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
[],
['declarationAccepted'],
];
const ANSWER_KEYS = Object.keys(SEAFARER_REGISTRATION_FIELD_LABELS) as AnswerKey[];
function answersOf(registration: SeafarerRegistration): SaveSeafarerRegistration {
return Object.fromEntries(ANSWER_KEYS.map((k) => [k, registration[k]])) as SaveSeafarerRegistration;
}
function blank(value: unknown): boolean {
return value === null || value === undefined || value === '' || value === false;
}
/**
* Fills blank answers from the profile.
*
* The API prefills a draft when it is opened, but an applicant who declared
* "seafarer" at onboarding lands here before their profile has an address —
* so whatever they fill in on /profile afterwards would never reach a draft
* already open. Blank-only: an answer the applicant typed or the server saved
* is left alone.
*/
function withProfileDefaults(
answers: SaveSeafarerRegistration,
profile: CurrentProfile | undefined,
accountName: string | undefined,
): SaveSeafarerRegistration {
if (!profile) return answers;
const a = profile.address;
const parts = accountName ? splitPersonName(accountName) : null;
const defaults: SaveSeafarerRegistration = {
firstName: profile.firstName || parts?.firstName || null,
middleName: profile.middleName || parts?.middleName || null,
lastName: profile.lastName || parts?.lastName || null,
gender: (profile.gender as SaveSeafarerRegistration['gender']) || null,
dateOfBirth: profile.dob ? profile.dob.slice(0, 10) : null,
maritalStatus: (profile.maritalStatus as SaveSeafarerRegistration['maritalStatus']) || null,
placeOfBirth: profile.pob || null,
nationality: a?.nationality || null,
nationalIdNumber: a?.idType === 'NID' ? a.idNumber || null : null,
passportNumber: a?.passportNumber || null,
passportExpiry: a?.passportExpiry || null,
permanentAddress: a?.streetAddress || null,
currentAddress: a?.currentAddress || null,
emergencyContactName: a?.emergencyContactName || null,
emergencyContactPhone: a?.emergencyContactPhone || null,
emergencyContactRelationship: a?.emergencyContactRelation || null,
department: profile.seafarerDepartment || null,
};
const next = { ...answers };
for (const [key, value] of Object.entries(defaults) as [AnswerKey, unknown][]) {
if (blank(next[key]) && !blank(value)) (next as Record<string, unknown>)[key] = value;
}
return next;
}
/**
* Seafarer registration — a fixed five-step form, not a configured wizard.
*
* A draft is opened on first visit so uploads have an owner and nothing is
* lost if the browser closes mid-way. Each "Continue" validates the step and
* saves it; Submit saves everything and asks the API, which names anything
* still missing. A submitted registration opens to a read-only summary.
*/
export function SeafarerRegistrationPage() {
const accountUser = useAppSelector((state) => state.auth.user);
const { profile } = useCurrentProfile();
const { data, isLoading } = useGetMySeafarerRegistrationQuery();
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false);
useEffect(() => {
if (isLoading || registration || started.current) return;
started.current = true;
start()
.unwrap()
.catch((err) => setStartError(extractErrorMessage(err)));
}, [isLoading, registration, start]);
const { data: attachments = [], refetch: refetchAttachments } = useGetAttachmentsQuery(
{ ownerType: 'SEAFARER_REGISTRATION', ownerId: registration?.id ?? '' },
{ skip: !registration },
);
const [active, setActive] = useState(0);
const [viewingSummary, setViewingSummary] = useState(true);
const [form, setForm] = useState<SaveSeafarerRegistration>({});
const [errors, setErrors] = useState<Partial<Record<AnswerKey, string>>>({});
const [issues, setIssues] = useState<ValidationIssue[]>([]);
const accountName = accountUser?.name?.en ?? profile?.user?.name?.en;
const isDraft = registration?.status === 'DRAFT';
// Seed local edits from the server copy when the registration (or its
// round) changes — not on every refetch, which would wipe typing in progress.
useEffect(() => {
if (!registration) return;
const answers = answersOf(registration);
setForm(isDraft ? withProfileDefaults(answers, profile, accountName) : answers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registration?.id, registration?.status]);
// The profile can arrive after the draft did; fill what is still blank.
useEffect(() => {
if (!isDraft || !profile) return;
setForm((prev) => withProfileDefaults(prev, profile, accountName));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile?.id, profile?.address?.id, isDraft]);
if (startError) {
return (
<Container size="md" py="xl">
<Alert color={profile?.seafarerNumber ? 'teal' : 'red'} icon={<IconInfoCircle size={16} />} title="Seafarer Registration">
{profile?.seafarerNumber
? `You are already registered as a seafarer (${profile.seafarerNumber}).`
: startError}
</Alert>
</Container>
);
}
if (isLoading || !registration) {
return (
<Center h={400}>
<Loader />
</Center>
);
}
const isAdjusting = registration.status === 'RESUBMIT_REQUIRED';
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(registration.status);
const showSummary = registration.status !== 'DRAFT' && viewingSummary;
function set(key: AnswerKey, value: unknown) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
function validateStep(index: number): boolean {
const found: Partial<Record<AnswerKey, string>> = {};
for (const key of REQUIRED_BY_STEP[index] ?? []) {
if (blank(form[key])) found[key] = `${SEAFARER_REGISTRATION_FIELD_LABELS[key]} is required.`;
}
if (index === 1) {
const { heightCm, weightKg } = PHYSICAL_BOUNDS;
if (typeof form.heightCm === 'number' && (form.heightCm < heightCm.min || form.heightCm > heightCm.max)) {
found.heightCm = `Enter a height between ${heightCm.min} and ${heightCm.max} cm.`;
}
if (typeof form.weightKg === 'number' && (form.weightKg < weightKg.min || form.weightKg > weightKg.max)) {
found.weightKg = `Enter a weight between ${weightKg.min} and ${weightKg.max} kg.`;
}
}
setErrors(found);
const count = Object.keys(found).length;
if (count) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
});
return false;
}
if (index === 3) {
const supplied = new Set(attachments.filter((a) => a.files?.length).map((a) => a.documentKey));
const missing = documentSlots(Boolean(form.passportNumber))
.filter((d) => d.isRequired && !supplied.has(d.key))
.map((d) => d.name);
if (missing.length) {
notifications.show({
color: 'red',
title: 'Documents missing',
message: `Upload: ${missing.join(', ')}.`,
});
return false;
}
}
return true;
}
async function saveAnswers(): Promise<boolean> {
if (readOnly || !registration) return true;
try {
await save({ id: registration.id, body: form }).unwrap();
return true;
} catch (err) {
notifications.show({ color: 'red', title: 'Could not save', message: extractErrorMessage(err) });
return false;
}
}
async function goToStep(target: number) {
if (target <= active) {
setActive(target);
return;
}
// Going forward validates every step passed over, so a jump cannot skip a
// required field; the walk stops on the first step that fails.
for (let step = active; step < target; step++) {
if (!readOnly && !validateStep(step)) {
setActive(step);
return;
}
}
if (!(await saveAnswers())) return;
setErrors({});
setActive(target);
}
async function handleSubmit() {
if (!registration) return;
setIssues([]);
if (!readOnly && !validateStep(4)) return;
if (!(await saveAnswers())) return;
try {
await submit(registration.id).unwrap();
notifications.show({
color: 'teal',
title: isAdjusting ? 'Resubmitted' : 'Registration submitted',
message: isAdjusting
? 'Your corrections were sent back to the reviewing officer.'
: 'You will be notified as it progresses.',
});
setViewingSummary(true);
} catch (err) {
const found = extractValidationIssues(err);
setIssues(found);
notifications.show({
color: 'red',
title: 'Registration incomplete',
message: found.length ? `${found.length} item(s) still need attention.` : extractErrorMessage(err),
});
}
}
const stepProps = { form, set, errors, disabled: readOnly };
return (
<Container size="lg" py="md">
<Group justify="space-between" mb="xs" align="flex-start">
<div>
<Title order={3}>Seafarer Registration</Title>
<Group gap="xs" mt={4}>
<Text size="sm" c="dimmed">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
</Group>
</div>
{showSummary && !readOnly && (
<Button size="xs" variant="default" leftSection={<IconPencil size={14} />} onClick={() => setViewingSummary(false)}>
Edit details
</Button>
)}
</Group>
{registration.status === 'APPROVED' && (
<Alert color="teal" icon={<IconCheck size={16} />} title="Registered" mb="md">
You are a registered seafarer. Your seafarer number is <b>{registration.seafarerNumber}</b>.
Your Seaman Book and Basic Training Certificate applications have been opened for you.
</Alert>
)}
{registration.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Registration rejected" mb="md">
{registration.rejectionReason}
</Alert>
)}
{isAdjusting && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Corrections requested" mb="md">
{registration.reviewRemark}
</Alert>
)}
{registration.status === 'SUBMITTED' && (
<Alert color="blue" icon={<IconInfoCircle size={16} />} title="Submitted" mb="md">
Your registration is with the Authority for review. You will be notified of the outcome, or
asked for corrections if anything is missing.
</Alert>
)}
{issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
<Stack gap={2}>
{issues.map((issue, i) => (
<Text size="sm" key={i}>
{issue.message}
</Text>
))}
</Stack>
</Alert>
)}
{showSummary && (
<Paper withBorder p="lg" radius="md">
<RegistrationSummary answers={answersOf(registration)} attachments={attachments} />
</Paper>
)}
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => (
<Stepper.Step key={label} label={label} />
))}
</Stepper>
{active === 0 && (
<IdentityDetailsStep
{...stepProps}
account={{ email: accountUser?.email, phoneNumber: accountUser?.phoneNumber }}
/>
)}
{active === 1 && <ApplicantDetailsStep {...stepProps} />}
{active === 2 && <EmergencyContactStep {...stepProps} />}
{active === 3 && (
<RegistrationDocuments
registrationId={registration.id}
passportDeclared={Boolean(form.passportNumber)}
attachments={attachments}
readOnly={readOnly}
onUploaded={refetchAttachments}
/>
)}
{active === 4 && (
<Stack>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">
Declaration
</Text>
<Grid>
<CheckboxField
{...stepProps}
name="declarationAccepted"
label="I declare that the information provided is complete and accurate."
required
/>
</Grid>
<Divider my="md" />
<Title order={5}>Review</Title>
<RegistrationSummary answers={form} attachments={attachments} />
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => setActive((s) => Math.max(0, s - 1))} disabled={active === 0}>
Back
</Button>
{active < STEPS.length - 1 ? (
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
) : (
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}
</Group>
</Paper>
)}
</Container>
);
}
export default SeafarerRegistrationPage;

View File

@@ -1,117 +0,0 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
export const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
export function seaServiceColumns(
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{
header: 'From',
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}
export function medicalColumns(
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10);
return [
{
header: 'Issuer',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</>
),
},
{
header: 'Issued',
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),
},
{
header: 'Fitness',
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -1,26 +1,30 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function seaServiceActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
}): AdvancedColumn<SeaServiceRecord> {
export function seaServiceActionsColumn(
t: TFunction,
handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
},
): AdvancedColumn<SeaServiceRecord> {
return {
header: '',
label: 'Actions',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const record = row.original;
const locked = record.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<Tooltip label={t('seaRecords.actions.evidence')}>
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(record)}
@@ -30,7 +34,7 @@ export function seaServiceActionsColumn(handlers: {
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<Tooltip label={locked ? t('seaRecords.actions.frozen') : t('seaRecords.actions.edit')}>
<ActionIcon
variant="subtle"
disabled={locked}
@@ -39,7 +43,7 @@ export function seaServiceActionsColumn(handlers: {
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<Tooltip label={locked ? t('seaRecords.actions.frozen') : t('seaRecords.actions.delete')}>
<ActionIcon
variant="subtle"
color="red"
@@ -57,23 +61,26 @@ export function seaServiceActionsColumn(handlers: {
};
}
export function medicalActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
}): AdvancedColumn<MedicalCertificate> {
export function medicalActionsColumn(
t: TFunction,
handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
},
): AdvancedColumn<MedicalCertificate> {
return {
header: '',
label: 'Actions',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<Tooltip label={t('seaRecords.actions.scanEvidence')}>
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(certificate)}
@@ -83,7 +90,9 @@ export function medicalActionsColumn(handlers: {
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<Tooltip
label={locked ? t('seaRecords.actions.certificatesFrozen') : t('seaRecords.actions.edit')}
>
<ActionIcon
variant="subtle"
disabled={locked}
@@ -92,7 +101,9 @@ export function medicalActionsColumn(handlers: {
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<Tooltip
label={locked ? t('seaRecords.actions.certificatesFrozen') : t('seaRecords.actions.delete')}
>
<ActionIcon
variant="subtle"
color="red"

View File

@@ -0,0 +1,152 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
export function fitnessOptions(t: TFunction) {
return [
{ value: 'FIT', label: t('seaRecords.columns.fitnessOptions.FIT') },
{
value: 'FIT_WITH_RESTRICTIONS',
label: t('seaRecords.columns.fitnessOptions.FIT_WITH_RESTRICTIONS'),
},
{ value: 'UNFIT', label: t('seaRecords.columns.fitnessOptions.UNFIT') },
];
}
export function seaServiceColumns(
t: TFunction,
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: t('seaRecords.columns.vessel'),
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
{t('seaRecords.columns.imo', { number: row.original.imoNumber })}
</Text>
)}
{(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && (
<Text size="xs" c="dimmed">
{[
row.original.vesselType,
row.original.flagState,
row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null,
]
.filter(Boolean)
.join(' · ')}
</Text>
)}
</>
),
},
{ header: t('seaRecords.columns.rank'), accessorKey: 'rank' },
{
header: t('seaRecords.columns.from'),
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: t('seaRecords.columns.to'),
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: t('seaRecords.columns.days', { defaultValue: 'Days' }),
align: 'right',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'}
</Text>
),
},
{
header: t('common.status'),
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
</Tooltip>
),
},
];
}
export function medicalColumns(
t: TFunction,
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10);
const options = fitnessOptions(t);
return [
{
header: t('seaRecords.columns.issuer'),
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{t('seaRecords.columns.certNumber', { number: row.original.certificateNumber })}
</Text>
)}
</>
),
},
{
header: t('seaRecords.columns.issued'),
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: t('seaRecords.columns.expires'),
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && (
<Badge color="red">{t('seaRecords.columns.expired')}</Badge>
)}
</Group>
),
},
{
header: t('seaRecords.columns.fitness'),
cell: ({ row }) =>
options.find((o) => o.value === row.original.fitnessStatus)?.label ??
row.original.fitnessStatus,
},
{
header: t('common.status'),
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -12,7 +12,6 @@ import {
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
Textarea,
@@ -27,10 +26,18 @@ import {
IconStethoscope,
} from '@tabler/icons-react';
import { useState } from 'react';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import {
AdvancedTable,
AmharicDatePicker,
notify,
PdfPreviewModal,
useServerTable,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
seaServiceDays,
uploadDocument,
useCreateMedicalCertificateMutation,
useCreateSeaServiceRecordMutation,
@@ -49,7 +56,7 @@ import {
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
import { seaServiceColumns, medicalColumns, fitnessOptions } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
/**
@@ -68,47 +75,39 @@ function EvidenceModal({
ownerId: string | null;
onClose: () => void;
}) {
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
const { t } = useTranslation();
const { data: attachments, 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 [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const files = (attachments ?? []).flatMap((a) => a.files);
return (
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered>
<Modal opened={Boolean(ownerId)} onClose={onClose} title={t('seaRecords.evidence.title')} centered>
<Stack>
{isLoading ? (
<Loader size="sm" />
<Loader size="sm" type="oval" />
) : files.length === 0 ? (
<Text size="sm" c="dimmed">
No evidence uploaded yet.
{t('seaRecords.evidence.none')}
</Text>
) : (
files.map((file) => (
<Group key={file.id} gap="xs">
<IconPaperclip size={16} />
{file.url ? (
<Anchor href={file.url} target="_blank" size="sm">
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
setPreview({ url: file.url as string, title: file.originalName })
}
>
{file.originalName}
</Anchor>
) : (
@@ -117,25 +116,52 @@ function EvidenceModal({
</Group>
))
)}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</RequirePermission>
</Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal>
);
}
/**
* Evidence picker that lives inside the add/edit form. The file is held in
* component state and uploaded right after the record is saved, because the
* attachment needs an owner id that only exists once the record does.
*/
function EvidenceField({
file,
onChange,
}: {
file: File | null;
onChange: (file: File | null) => void;
}) {
const { t } = useTranslation();
return (
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<Group gap="sm" align="center">
<FileButton onChange={onChange} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
leftSection={<IconFileUpload size={16} />}
>
{t('seaRecords.evidence.upload')}
</Button>
)}
</FileButton>
<Text size="sm" c={file ? undefined : 'dimmed'}>
{file ? file.name : t('seaRecords.evidence.none')}
</Text>
</Group>
</RequirePermission>
);
}
// ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = {
@@ -150,6 +176,7 @@ const EMPTY_SEA_SERVICE = {
};
function SeaServiceTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
@@ -165,11 +192,15 @@ function SeaServiceTab() {
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploadingEvidence, setUploadingEvidence] = useState(false);
const [uploading, setUploading] = useState(false);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_SEA_SERVICE);
setGrossTonnage('');
setEvidenceFile(null);
setModalOpen(true);
};
@@ -186,6 +217,7 @@ function SeaServiceTab() {
dutiesDescription: record.dutiesDescription ?? '',
});
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setEvidenceFile(null);
setModalOpen(true);
};
@@ -204,25 +236,43 @@ function SeaServiceTab() {
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
};
try {
let recordId = editing?.id;
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
await createRecord(body).unwrap();
const created = await createRecord(body).unwrap();
recordId = created.id;
notify.success('Sea-service record added');
}
if (evidenceFile && recordId) {
setUploadingEvidence(true);
const result = await uploadDocument({
ownerType: 'SEA_SERVICE_RECORD',
ownerId: recordId,
documentKey: 'evidence',
file: evidenceFile,
});
setUploadingEvidence(false);
if (result.ok) {
notify.success('Evidence uploaded');
} else {
notify.error(result.error);
}
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record'));
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
}
};
const remove = async (record: SeaServiceRecord) => {
try {
await deleteRecord(record.id).unwrap();
notify.success('Record withdrawn');
notify.success(t('seaRecords.seaService.withdrawn'));
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not delete the record'));
notify.error(extractErrorMessage(error, t('seaRecords.seaService.deleteFailed')));
}
};
@@ -233,12 +283,21 @@ function SeaServiceTab() {
form.dischargeDate &&
form.engagementDate < form.dischargeDate;
// Shown under the date pickers as they are filled: the seafarer sees what
// the engagement is worth before saving it.
const formDays = seaServiceDays(form.engagementDate, form.dischargeDate);
// Every record as entered (verified or not), beside the approved figure.
const declaredDays = (records ?? []).reduce(
(sum, r) => sum + (seaServiceDays(r.engagementDate, r.dischargeDate) ?? 0),
0,
);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(records ?? []);
const columns = [
...seaServiceColumns(showDate),
seaServiceActionsColumn({
...seaServiceColumns(t, showDate),
seaServiceActionsColumn(t, {
can,
onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit,
@@ -251,25 +310,32 @@ function SeaServiceTab() {
<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.
{t('seaRecords.seaService.description')}
</Text>
{(records ?? []).length > 0 && (
<Badge variant="light" color="blue">
{t('seaRecords.seaService.declaredSeaTime', {
days: declaredDays,
defaultValue: 'Declared sea time: {{days}} days',
})}
</Badge>
)}
{seaTime && seaTime.verifiedRecords > 0 && (
<Badge variant="light" color="teal">
Approved sea time: {seaTime.totalDays} days
{t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })}
</Badge>
)}
</Group>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
{t('seaRecords.seaService.add')}
</Button>
</RequirePermission>
</Group>
{(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No sea-service records yet.
{t('seaRecords.seaService.empty')}
</Text>
</Paper>
) : (
@@ -277,7 +343,7 @@ function SeaServiceTab() {
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Sea service"
tableName={t('seaRecords.seaService.tableName')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -292,51 +358,51 @@ function SeaServiceTab() {
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'}
title={editing ? t('seaRecords.seaService.modal.editTitle') : t('seaRecords.seaService.modal.addTitle')}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Vessel name"
label={t('seaRecords.seaService.fields.vesselName')}
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/>
<TextInput
label="IMO number"
label={t('seaRecords.seaService.fields.imoNumber')}
value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/>
</Group>
<Group grow>
<TextInput
label="Vessel type"
label={t('seaRecords.seaService.fields.vesselType')}
value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/>
<TextInput
label="Flag state"
label={t('seaRecords.seaService.fields.flagState')}
value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/>
<NumberInput
label="Gross tonnage"
label={t('seaRecords.seaService.fields.grossTonnage')}
min={0}
value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/>
</Group>
<TextInput
label="Rank / capacity"
label={t('seaRecords.seaService.fields.rank')}
required
value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })}
/>
<Group grow>
<AmharicDatePicker
label="Engagement date"
label={t('seaRecords.seaService.fields.engagementDate')}
required
value={form.engagementDate}
onChange={(val) =>
@@ -345,7 +411,7 @@ function SeaServiceTab() {
dateFormat="date"
/>
<AmharicDatePicker
label="Discharge date"
label={t('seaRecords.seaService.fields.dischargeDate')}
required
value={form.dischargeDate}
onChange={(val) =>
@@ -354,23 +420,51 @@ function SeaServiceTab() {
dateFormat="date"
/>
</Group>
{form.engagementDate && form.dischargeDate && (
<Alert
variant="light"
color={formDays === null ? 'red' : 'teal'}
icon={<IconInfoCircle size={16} />}
py={6}
>
{formDays === null
? t('seaRecords.seaService.dateOrder', {
defaultValue: 'Discharge date must be after the engagement date.',
})
: t('seaRecords.seaService.daysServed', {
days: formDays,
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
})}
</Alert>
)}
<Textarea
label="Duties"
label={t('seaRecords.seaService.fields.duties')}
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
}
/>
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
leftSection={<IconFileUpload size={16} />}
>
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
</Button>
)}
</FileButton>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add record'}
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
</Button>
</Group>
</Stack>
@@ -397,6 +491,7 @@ const EMPTY_MEDICAL = {
};
function MedicalTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
@@ -410,10 +505,13 @@ function MedicalTab() {
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL);
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploadingEvidence, setUploadingEvidence] = useState(false);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_MEDICAL);
setEvidenceFile(null);
setModalOpen(true);
};
@@ -427,6 +525,7 @@ function MedicalTab() {
fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '',
});
setEvidenceFile(null);
setModalOpen(true);
};
@@ -442,27 +541,43 @@ function MedicalTab() {
...(form.restrictions ? { restrictions: form.restrictions } : {}),
};
try {
let certificateId = editing?.id;
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
await createCertificate(body).unwrap();
const created = await createCertificate(body).unwrap();
certificateId = created.id;
notify.success('Medical certificate added');
}
if (evidenceFile && certificateId) {
setUploadingEvidence(true);
const result = await uploadDocument({
ownerType: 'MEDICAL_CERTIFICATE',
ownerId: certificateId,
documentKey: 'evidence',
file: evidenceFile,
});
setUploadingEvidence(false);
if (result.ok) {
notify.success('Evidence uploaded');
} else {
notify.error(result.error);
}
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
}
};
const remove = async (certificate: MedicalCertificate) => {
try {
await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn');
notify.success(t('seaRecords.medical.withdrawn'));
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not delete the certificate'),
);
notify.error(extractErrorMessage(error, t('seaRecords.medical.deleteFailed')));
}
};
@@ -476,8 +591,8 @@ function MedicalTab() {
const page = paginate(certificates ?? []);
const columns = [
...medicalColumns(showDate),
medicalActionsColumn({
...medicalColumns(t, showDate),
medicalActionsColumn(t, {
can,
onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit,
@@ -489,19 +604,18 @@ function MedicalTab() {
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new
applications that require one.
{t('seaRecords.medical.description')}
</Text>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
{t('seaRecords.medical.add')}
</Button>
</RequirePermission>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No medical certificates yet.
{t('seaRecords.medical.empty')}
</Text>
</Paper>
) : (
@@ -509,7 +623,7 @@ function MedicalTab() {
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Medical certificates"
tableName={t('seaRecords.medical.tableName')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -524,20 +638,20 @@ function MedicalTab() {
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
title={editing ? t('seaRecords.medical.modal.editTitle') : t('seaRecords.medical.modal.addTitle')}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Issuing clinic / physician"
label={t('seaRecords.medical.fields.issuerName')}
required
value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/>
<TextInput
label="Certificate number"
label={t('seaRecords.medical.fields.certificateNumber')}
value={form.certificateNumber}
onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value })
@@ -546,14 +660,14 @@ function MedicalTab() {
</Group>
<Group grow>
<AmharicDatePicker
label="Issue date"
label={t('seaRecords.medical.fields.issueDate')}
required
value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })}
dateFormat="date"
/>
<AmharicDatePicker
label="Expiry date"
label={t('seaRecords.medical.fields.expiryDate')}
required
value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })}
@@ -561,30 +675,41 @@ function MedicalTab() {
/>
</Group>
<Select
label="Fitness outcome"
data={FITNESS_OPTIONS}
label={t('seaRecords.medical.fields.fitnessOutcome')}
data={fitnessOptions(t)}
value={form.fitnessStatus}
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
/>
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
<Textarea
label="Restrictions"
label={t('seaRecords.medical.fields.restrictions')}
value={form.restrictions}
onChange={(e) =>
setForm({ ...form, restrictions: e.target.value })
}
/>
)}
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
leftSection={<IconFileUpload size={16} />}
>
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
</Button>
)}
</FileButton>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add certificate'}
{editing ? t('common.save') : t('seaRecords.medical.add')}
</Button>
</Group>
</Stack>
@@ -599,39 +724,36 @@ function MedicalTab() {
);
}
/**
* 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() {
/** Sea-service history (US-SSM-001): every engagement, its days, its evidence. */
export function SeaServicePage() {
const { t } = useTranslation();
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.
<Group gap="xs">
<IconAnchor size={22} />
<Title order={2}>{t('seaRecords.tabs.seaService')}</Title>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
{t('seaRecords.pageIntro')}
</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>
<SeaServiceTab />
</Stack>
);
}
/** Medical fitness certificates (US-SSM-006), editable until verified. */
export function MedicalRecordsPage() {
const { t } = useTranslation();
return (
<Stack>
<Group gap="xs">
<IconStethoscope size={22} />
<Title order={2}>{t('seaRecords.tabs.medical')}</Title>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
{t('seaRecords.pageIntro')}
</Alert>
<MedicalTab />
</Stack>
);
}

View File

@@ -1,595 +0,0 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconBook2,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconHeart,
IconId,
IconInfoCircle,
IconShieldCheck,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'Relevant Certificate' },
{ label: 'Medical Certificate' },
{ label: 'Payment' },
{ label: 'Review & Submit' },
];
// ---------------------------------------------------------------------------
// Fee table — Seaman Book + BTC shown separately, paid together
// ---------------------------------------------------------------------------
const FEES = [
{ label: 'Seaman Book — Application Fee', amount: 500 },
{ label: 'Seaman Book — Document Verification Fee',amount: 200 },
{ label: 'Basic Training Certificate (BTC) — Application Fee', amount: 300 },
{ label: 'BTC — Document Verification Fee', amount: 100 },
{ label: 'BSID — Application Fee', amount: 100 },
{ label: 'BSID — Card Production Fee', amount: 150 },
];
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
// ---------------------------------------------------------------------------
// 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>
);
}
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>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeamanBookApplicationPage() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Relevant Certificate
const [relCertNumber, setRelCertNumber] = useState('');
const [relIssuer, setRelIssuer] = useState('');
const [relIssueDate, setRelIssueDate] = useState('');
const [relExpiryDate, setRelExpiryDate] = useState('');
const [relFile, setRelFile] = useState<File | null>(null);
const relResetRef = useRef<() => void>(null);
// Medical
const [medCertNumber, setMedCertNumber] = useState('');
const [medIssuer, setMedIssuer] = useState('');
const [medIssueDate, setMedIssueDate] = useState('');
const [medExpiryDate, setMedExpiryDate] = useState('');
const [medFile, setMedFile] = useState<File | null>(null);
const medResetRef = useRef<() => void>(null);
// Payment
const [paymentMethod, setPaymentMethod] = useState<'cbe' | 'telebirr' | null>(null);
const [paymentRef, setPaymentRef] = useState('');
const [paymentDate, setPaymentDate] = useState('');
const [paymentFile, setPaymentFile] = useState<File | null>(null);
const payResetRef = useRef<() => void>(null);
// Validation
const relComplete = !!relFile && !!relCertNumber.trim() && !!relIssuer.trim() && !!relIssueDate && !!relExpiryDate;
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
const canNext = () => {
if (active === 0) return relComplete;
if (active === 1) return medComplete;
if (active === 2) return payComplete;
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 new Promise((r) => setTimeout(r, 1400));
notify.success('Application submitted! Reference: SB-BTC-2025-001');
navigate('/seaman-book');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Title order={3}>Seaman Book, BTC & BSID Application</Title>
<Text fz="sm" c="dimmed">
One application covers your Seaman Book, Basic Training Certificate (BTC), and BSID Step {active + 1} of {STEPS.length}
</Text>
</div>
{/* What you will receive banner */}
<Paper withBorder radius="md" p="md" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-2)' }}>
<Group gap="lg" wrap="wrap">
<Group gap="xs">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="blue.8">Seaman Book</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="teal.8">Basic Training Certificate (BTC)</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconId size={18} color="var(--mantine-color-violet-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="violet.8">BSID (Biometric Seafarer ID)</Text>
</Group>
</Group>
</Paper>
<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 1: Relevant Certificate ────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload your relevant certificate issued by an EMA-approved training institution. This is the prerequisite for your Basic Training Certificate (BTC).
</Alert>
<SectionHead title="Relevant Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Certificate Number"
placeholder="e.g. CERT-2024-001"
required
value={relCertNumber}
onChange={(e) => setRelCertNumber(e.currentTarget.value)}
/>
<TextInput
label="Issuing Institution"
placeholder="e.g. Bahirdar Maritime School"
required
value={relIssuer}
onChange={(e) => setRelIssuer(e.currentTarget.value)}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Issue Date"
type="date"
required
value={relIssueDate}
onChange={(e) => setRelIssueDate(e.currentTarget.value)}
/>
<TextInput
label="Expiry Date"
type="date"
required
value={relExpiryDate}
onChange={(e) => setRelExpiryDate(e.currentTarget.value)}
/>
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: relFile ? 'solid' : 'dashed',
borderColor: relFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: relFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconShieldCheck size={20} color={relFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Relevant Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{relFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{relFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setRelFile(null); relResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={relResetRef} onChange={setRelFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
</Stack>
)}
{/* ── Step 2: Medical ─────────────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload your valid medical certificate from an EMA-approved medical centre. Required for both Seaman Book and BTC issuance.
</Alert>
<SectionHead title="Medical Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Certificate Number" placeholder="e.g. MC-2024-001" required value={medCertNumber} onChange={(e) => setMedCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Medical Centre" placeholder="e.g. EMA Medical Centre" required value={medIssuer} onChange={(e) => setMedIssuer(e.currentTarget.value)} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Issue Date" type="date" required value={medIssueDate} onChange={(e) => setMedIssueDate(e.currentTarget.value)} />
<TextInput label="Expiry Date" type="date" required value={medExpiryDate} onChange={(e) => setMedExpiryDate(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card withBorder radius="md" p="md" style={{
borderStyle: medFile ? 'solid' : 'dashed',
borderColor: medFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: medFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconHeart size={20} color={medFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Medical Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{medFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{medFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setMedFile(null); medResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={medResetRef} onChange={setMedFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Only certificates from <strong>EMA-approved medical centres</strong> are accepted.
</Alert>
</Stack>
)}
{/* ── Step 3: Payment ─────────────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
{/* Fee breakdown — SB + BTC shown separately */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={700} fz="sm" mb="xs">Fee Breakdown</Text>
<Text fz="xs" c="dimmed" mb="md">Your payment covers both the Seaman Book and Basic Training Certificate (BTC).</Text>
{/* SB fees */}
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>Seaman Book</Text>
{FEES.filter(f => f.label.startsWith('Seaman Book')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Seaman Book — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BTC fees */}
<Text fz="xs" fw={700} tt="uppercase" c="teal.7" mb={6}>Basic Training Certificate (BTC)</Text>
{FEES.filter(f => f.label.startsWith('Basic Training') || f.label.startsWith('BTC')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Basic Training Certificate (BTC) — ', '').replace('BTC — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BSID fees */}
<Divider my="xs" />
<Text fz="xs" fw={700} tt="uppercase" c="violet.7" mb={6}>BSID (Biometric Seafarer ID)</Text>
{FEES.filter(f => f.label.startsWith('BSID')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('BSID — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="xs" mb="sm" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total Amount Due</Text>
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
</Group>
</Paper>
<SectionHead title="Select Payment Method" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" style={{ maxWidth: rem(600) }}>
{/* CBE */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('cbe'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'cbe' ? 2 : 1,
background: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">CBE Bank Transfer</Text>
<Text fz="xs" c="dimmed">Commercial Bank of Ethiopia</Text>
</div>
{paymentMethod === 'cbe' && <IconCircleCheck size={18} color="var(--mantine-color-blue-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
{/* Telebirr */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('telebirr'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'telebirr' ? 2 : 1,
background: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-violet-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-violet-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">Telebirr</Text>
<Text fz="xs" c="dimmed">Ethio Telecom Mobile Money</Text>
</div>
{paymentMethod === 'telebirr' && <IconCircleCheck size={18} color="var(--mantine-color-violet-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
</SimpleGrid>
{paymentMethod === 'cbe' && (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
Transfer <strong>ETB {TOTAL.toFixed(2)}</strong> to CBE Account <strong>1000123456789</strong> (EMA Maritime Authority). Use your full name as description.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="CBE Transaction Reference" placeholder="e.g. CBE-TXN-20240510-001" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod === 'telebirr' && (
<>
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
Send <strong>ETB {TOTAL.toFixed(2)}</strong> to Telebirr <strong>+251 11 551 0000</strong> (EMA Maritime Authority). Screenshot and upload your confirmation.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Telebirr Transaction ID" placeholder="e.g. TLB-2024-001234" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod && (
<>
<SectionHead title="Upload Receipt (optional)" />
<Card withBorder radius="md" p="md" style={{
borderStyle: paymentFile ? 'solid' : 'dashed',
borderColor: paymentFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: paymentFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconCreditCard size={20} color={paymentFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}</Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{paymentFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{paymentFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setPaymentFile(null); payResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={payResetRef} onChange={setPaymentFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Upload Receipt
</Button>
)}
</FileButton>
)}
</Card>
</>
)}
</Stack>
)}
{/* ── Step 4: Review ──────────────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Submitting this application will initiate processing for both your <strong>Seaman Book</strong> and <strong>Basic Training Certificate (BTC)</strong>.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Relevant Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Certificate No." value={relCertNumber} />
<ReviewRow label="Issuing Institution" value={relIssuer} />
<ReviewRow label="Issue Date" value={relIssueDate} />
<ReviewRow label="Expiry Date" value={relExpiryDate} />
<ReviewRow label="Document" value={relFile?.name ?? '—'} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Medical Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Certificate No." value={medCertNumber} />
<ReviewRow label="Issuing Centre" value={medIssuer} />
<ReviewRow label="Issue Date" value={medIssueDate} />
<ReviewRow label="Expiry Date" value={medExpiryDate} />
<ReviewRow label="Document" value={medFile?.name ?? '—'} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Payment</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
<ReviewRow label="Transaction Reference" value={paymentRef} />
<ReviewRow label="Payment Date" value={paymentDate} />
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
</SimpleGrid>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/seaman-book')}>Cancel</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={<IconBook2 size={16} />} onClick={handleSubmit} loading={submitting}>
Submit Application
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,13 +1,11 @@
import { useNavigate } from 'react-router-dom';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
Center,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
@@ -15,356 +13,249 @@ import {
Text,
ThemeIcon,
Title,
} from '@mantine/core';
} from "@mantine/core";
import {
IconAlertCircle,
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
interface SeamanBookOverview {
application: {
id: string;
applicationId: string;
status: string;
submittedAt: string;
} | null;
book: {
id: string;
issuedDate: string;
expiryDate: string;
status: string;
} | null;
eligibility: {
hasProfile: boolean;
hasSeafarerNumber: boolean;
hasMedical: boolean;
medicalExpiry: string | null;
bstComplete: boolean;
bstModules: { key: string; label: string; done: boolean }[];
};
eligible: boolean;
}
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
useBypassDocumentPaymentMutation,
useGetMySeafarerDocumentsQuery,
useGetPaymentCapabilitiesQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
type SeafarerDocument,
type SeafarerDocumentStatus,
} from "@ema-platform/api";
import { useApplicationPayment } from "../../payments/hooks/useApplicationPayment";
/**
* The stages an application passes through, for the progress stepper.
*
* Derived from the application's status rather than stored as a timeline:
* the status is what the workflow actually moves, so a second record of the
* same journey would only drift out of step with it.
* The stages a document passes through, for the progress stepper. Derived
* from the status the API moves, never stored separately.
*/
const STAGES: { label: string; statuses: string[] }[] = [
{ label: 'Submitted', statuses: ['SUBMITTED', 'UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] },
{ label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] },
{ label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] },
{ label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] },
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
{ label: "Pickup Scheduled", statuses: ["SCHEDULED"] },
{ label: "Issued", statuses: ["ISSUED"] },
];
/** How far along the stepper a status sits; -1 for a draft. */
function stageIndexFor(status: string | undefined): number {
if (!status || status === 'DRAFT') return -1;
function stageIndexFor(status: SeafarerDocumentStatus): number {
let reached = -1;
STAGES.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i;
});
// A status past the last named stage (e.g. REJECTED) still shows the
// journey taken rather than collapsing the stepper to nothing.
return reached;
}
// Keyed by the workflow's own status values, not display strings: the badge
// reads whatever the API reports, and an unmapped status falls back to grey
// rather than vanishing.
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'yellow',
UNDER_EVALUATION: 'yellow',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'grape',
INSPECTION_COMPLETED: 'grape',
APPROVED: 'teal',
REJECTED: 'red',
ON_HOLD: 'orange',
PAYMENT_PENDING: 'orange',
PAID: 'blue',
PAYMENT_CONFIRMED: 'blue',
CERTIFICATE_ISSUED: 'teal',
COMPLETED: 'teal',
};
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
return new Date(value).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
/** One document: where it stands, what the applicant can do about it now. */
function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onChanged: () => void }) {
const { payDocument, isPaying } = useApplicationPayment();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status);
async function download() {
try {
const { url } = await getDownload(document.id).unwrap();
window.open(url, "_blank", "noopener");
} catch (err) {
notifications.show({ color: "red", title: "Download failed", message: extractErrorMessage(err) });
}
}
return (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
</Group>
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>
{title} {document.documentNumber ?? document.requestNumber}
</Text>
<Text fz="xs" c="dimmed">
Requested {formatDate(document.createdAt)}
{document.feeAmount !== null && ` · Fee ${document.feeAmount} ${document.feeCurrency}`}
</Text>
</div>
</Group>
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
</Group>
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? "Done" : "Pending"}
icon={i <= activeStep ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
)}
{document.status === "AWAITING_REGISTRATION" && (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />} mt="md">
Requested with your seafarer registration. It moves to payment as soon as the
registration is approved.
</Alert>
)}
{document.status === "PAYMENT_PENDING" && (
<Group mt="md">
<Button loading={isPaying} onClick={() => payDocument(document.id)}>
Pay now
</Button>
{capabilities?.bypassEnabled && (
<Button
variant="default"
loading={bypassing}
onClick={async () => {
await bypass(document.id).unwrap();
onChanged();
}}
>
Complete test payment
</Button>
)}
</Group>
)}
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
Payment received. The Authority will schedule a date for you to collect your {title}.
</Alert>
)}
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
<Alert variant="light" color="grape" icon={<IconPrinter size={17} />} mt="md">
Your {title} is ready for collection on{" "}
<strong>{formatDate(document.scheduledIssuanceDate)}</strong>. Please visit the EMA
office on that date, bringing your National ID.
</Alert>
)}
{document.status === "ISSUED" && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
<Group justify="space-between" wrap="wrap">
<span>
Your {title} <strong>{document.documentNumber}</strong> was issued
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
</span>
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
</Group>
</Alert>
)}
{document.status === "REJECTED" && (
<Alert variant="light" color="red" icon={<IconInfoCircle size={17} />} mt="md">
{document.rejectionReason ?? "This request was rejected."}
</Alert>
)}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
const navigate = useNavigate();
const { data, isLoading } = useApiQuery<SeamanBookOverview>({
url: '/seaman-book/my',
method: 'GET',
/**
* The Seaman Book and Basic Training Certificate — requested automatically
* with the seafarer registration, tracked here through payment, collection
* and issue.
*/
export function SeamanBookPage({ service = "COMBINED" }: { service?: "COMBINED" | "SEAMAN_BOOK" | "BTC" }) {
// Polled: payment confirmation, scheduling and issue happen in other sessions.
const { data, isLoading, refetch } = useGetMySeafarerDocumentsQuery(undefined, {
pollingInterval: 15_000,
});
const application = data?.application ?? null;
const eligibility = data?.eligibility;
const bstItems = eligibility?.bstModules ?? [];
const bstDone = bstItems.filter((b) => b.done).length;
// The server decides: the same checklist gates the submission, so a screen
// that judged eligibility for itself could offer a button the API refuses.
const isEligible = data?.eligible ?? false;
const submitted = Boolean(application);
const activeStep = stageIndexFor(application?.status);
const isBtc = service === "BTC";
const isCombined = service === "COMBINED";
const shown = [
...(isCombined || !isBtc ? [data?.seamanBook] : []),
...(isCombined || isBtc ? [data?.btc] : []),
].filter((d): d is SeafarerDocument => Boolean(d));
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Title order={3}>
{isCombined
? "Seaman Book & Basic Training Certificate"
: isBtc
? "Basic Training Certificate"
: "Seaman Book"}
</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records your sea service and must be
held before joining any vessel.
Both are requested for you when you register as a seafarer and released to payment once
the registration is approved. Each is paid for separately.
</Text>
</div>
{/* Active application status */}
{application && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</Group>
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
Please visit the EMA office to collect it, bringing your National ID.
</Alert>
)}
</Paper>
{isLoading ? (
<Center h={160}>
<Loader />
</Center>
) : shown.length === 0 ? (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Nothing requested yet. Complete and submit your seafarer registration a Seaman Book and a
Basic Training Certificate are applied for with it.
</Alert>
) : (
shown.map((document) => <DocumentCard key={document.id} document={document} onChanged={refetch} />)
)}
{/* No active application — eligibility + apply */}
{!submitted && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
</Group>
<Stack gap="sm">
<EligibilityItem
label="Profile completed (name, DOB, nationality)"
ok={Boolean(eligibility?.hasProfile)}
/>
<EligibilityItem
label="Registered seafarer number issued"
ok={Boolean(eligibility?.hasSeafarerNumber)}
/>
<EligibilityItem
label={
eligibility?.medicalExpiry
? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})`
: 'Valid medical certificate uploaded'
}
ok={Boolean(eligibility?.hasMedical)}
/>
<Divider
label={`Basic Safety Training (all ${bstItems.length || 5} required)`}
labelPosition="left"
my={4}
/>
{bstItems.map((item) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
))}
{!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs">
Complete all requirements above before applying.
{bstItems.length > bstDone
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
: ''}
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
<Text fw={700} fz="sm">
About the {isCombined ? "Seaman Book & Basic Training Certificate" : isBtc ? "Basic Training Certificate" : "Seaman Book"}
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
{(isBtc
? [
{ icon: IconShield, title: "STCW Training", desc: "Confirms completion of the required basic maritime safety training." },
{ icon: IconFileDescription, title: "Certificate Record", desc: "Keeps your approved basic training evidence available in one place." },
{ icon: IconCircleCheck, title: "Verified", desc: "Issued after EMA verifies the applicable training requirements." },
]
: [
{ icon: IconBook2, title: "Official Identity", desc: "Internationally recognized maritime identity document required before joining any vessel." },
{ icon: IconFileDescription, title: "Service Record", desc: "Records all your sea service, vessel assignments, and employment history." },
{ icon: IconShield, title: "STCW Compliance", desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career." },
]
).map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
<Text fz="sm" fw={600}>
{title}
</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
<Text fz="xs" c="dimmed" lh={1.5}>
{desc}
</Text>
</Box>
))}
</SimpleGrid>

View File

@@ -13,8 +13,6 @@ import {
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';

View File

@@ -5,7 +5,6 @@ import {
Anchor,
Box,
Button,
Center,
Divider,
Group,
Paper,

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Divider,

View File

@@ -0,0 +1,76 @@
import { Badge, Button, Group, Progress, Text } from '@mantine/core';
import { IconArrowRight } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
type LicenseApplication,
} from '@ema-platform/api';
/** Columns for the applicant's in-flight vessel registration applications. */
export function inFlightColumns(
t: TFunction,
deps: { onOpen: (app: LicenseApplication) => void },
): AdvancedColumn<LicenseApplication>[] {
return [
{
header: t('applications.table.applicationNumber'),
cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Text fw={600} fz="sm">{row.original.applicationNumber}</Text>
{row.original.kind === 'RENEWAL' && (
<Badge size="sm" variant="light">
{t('vesselRegistration.inFlight.renewalBadge')}
</Badge>
)}
</Group>
),
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{STATUS_LABELS[row.original.status]}
</Badge>
),
},
{
header: t('applications.table.progress'),
size: 140,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
),
},
{
header: '',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const app = row.original;
const needsAction = app.status === 'RESUBMIT_REQUIRED';
return (
<Button
size="compact-sm"
variant={needsAction ? 'filled' : 'light'}
color={needsAction ? 'orange' : undefined}
rightSection={<IconArrowRight size={14} />}
onClick={() => deps.onOpen(app)}
>
{app.status === 'DRAFT'
? t('common.continue')
: needsAction
? t('vesselRegistration.inFlight.fix')
: t('vesselRegistration.inFlight.view')}
</Button>
);
},
},
];
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
@@ -13,14 +14,12 @@ import {
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconAlertCircle,
IconFileDescription,
IconShieldCheck,
IconCertificate,
IconDownload,
@@ -28,12 +27,23 @@ import {
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
TERMINAL_STATUSES,
useApiMutation,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Registration applications run through the config-driven licensing wizard. */
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
const PAGE_SIZE = 5;
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';
@@ -120,6 +130,9 @@ function CertificateCard({ label, description }: { label: string; description: s
// ---------------------------------------------------------------------------
export function VesselRegistrationPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
const [page, setPage] = useState(0);
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
@@ -134,6 +147,19 @@ export function VesselRegistrationPage() {
.catch(() => {/* no registration yet */});
}, [fetchTrigger]);
// Drafts and anything still moving through review — the applicant's own
// registration applications, straight from the licensing queue.
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
const columns = inFlightColumns(t, {
onOpen: (app) =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
});
const certs = registration?.category === 'Sea-going Vessel (International)'
? SEAGOING_CERTIFICATES
: INLAND_CERTIFICATES;
@@ -150,6 +176,25 @@ export function VesselRegistrationPage() {
</div>
</Group>
{/* ── Drafts / submitted applications ───────────────────────────── */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Text fw={700} fz="md">{t('vesselRegistration.inFlight.title')}</Text>
<AdvancedTable
tableName="vessel-registration-in-flight"
columns={columns}
// Client-side paging: getMyApplications returns the whole list.
data={inFlight.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
itemCount={inFlight.length}
pageIndex={page}
pageSize={PAGE_SIZE}
refresh={refetch}
isLoading={isFetching}
onPageChange={setPage}
/>
</Stack>
)}
{/* ── No registration yet ───────────────────────────────────────── */}
{!registration && (
<>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
@@ -7,7 +8,6 @@ import {
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
@@ -38,6 +38,7 @@ function downloadCertificate(filename: string) {
}
export function VesselRegistrationStatusPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const { id } = useParams();
@@ -47,26 +48,30 @@ export function VesselRegistrationStatusPage() {
if (!reg) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert>
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>{t('common.back')}</Button>
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>{t('vesselRegistration.status.notFound')}</Alert>
</Stack>
);
}
const activeStep = reg.timeline.filter((t) => t.done).length - 1;
const activeStep = reg.timeline.filter((step) => step.done).length - 1;
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
const handleDownload = (certName: string, certNumber: string) => {
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
recordDownload(reg.id, certName);
forceUpdate((n) => n + 1);
notify.success(`${certName} downloaded.`);
notify.success(t('vesselRegistration.status.downloadedNotify', { certName }));
};
const renewalKey = reg.renewal === 'Overdue'
? (reg.expiryDate ? 'vesselRegistration.status.renewalOverdueWithExpiry' : 'vesselRegistration.status.renewalOverdue')
: (reg.expiryDate ? 'vesselRegistration.status.renewalDueSoonWithExpiry' : 'vesselRegistration.status.renewalDueSoon');
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>{t('common.back')}</Button>
<div>
<Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} {reg.category}</Text>
@@ -78,8 +83,8 @@ export function VesselRegistrationStatusPage() {
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Registration Status</Text>
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text>
<Text fw={700} fz="sm">{t('vesselRegistration.status.title')}</Text>
<Text fz="xs" c="dimmed">{t('vesselRegistration.status.submitted', { date: reg.submitted })}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
@@ -94,15 +99,14 @@ export function VesselRegistrationStatusPage() {
p="sm"
>
<Text fz="sm">
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
{reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}.
{t(renewalKey, { date: reg.expiryDate ? showDate(reg.expiryDate) : undefined })}
</Text>
</Alert>
)}
{reg.remarks && (
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text>
<Text fz="sm" fw={600} mb={2}>{t('vesselRegistration.status.officerRemarks')}</Text>
<Text fz="sm">{reg.remarks}</Text>
</Alert>
)}
@@ -112,7 +116,7 @@ export function VesselRegistrationStatusPage() {
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
description={step.date ?? t('vesselRegistration.status.pending')}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
@@ -120,23 +124,23 @@ export function VesselRegistrationStatusPage() {
{needsCorrection && (
<Group justify="flex-end" mt="md">
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button>
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>{t('vesselRegistration.status.resubmit')}</Button>
</Group>
)}
</Paper>
{reg.status === 'Approved' && reg.certificates && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Text fw={700} mb="md">{t('vesselRegistration.status.certificatesTitle')}</Text>
<Stack gap="sm">
{reg.certificates.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="sm">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {showDate(cert.issueDate)}</Text>
<Text fz="xs" c="dimmed">{t('vesselRegistration.status.certificateNumber', { number: cert.number, date: showDate(cert.issueDate) })}</Text>
{cert.downloads > 0 && (
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
<Text fz="xs" c="dimmed">{t('vesselRegistration.status.downloadedCount', { count: cert.downloads })}</Text>
)}
</div>
<Button
@@ -144,7 +148,7 @@ export function VesselRegistrationStatusPage() {
leftSection={<IconDownload size={14} />}
onClick={() => handleDownload(cert.name, cert.number)}
>
Download
{t('common.download')}
</Button>
</Group>
<Divider mt="sm" />

View File

@@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
@@ -29,11 +30,6 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
@@ -48,11 +44,17 @@ const VESSEL_STATUS_COLORS: Record<string, string> = {
* certificate/renewal/incident actions, which don't apply here.
*/
export function VesselTransferPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const categoryLabels: Record<string, string> = {
INLAND_WATERWAY: t('vesselTransfer.table.categories.INLAND_WATERWAY'),
SEA_GOING: t('vesselTransfer.table.categories.SEA_GOING'),
};
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === TRANSFER_TYPE_KEY &&
@@ -76,9 +78,9 @@ export function VesselTransferPage() {
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Ownership Transfer</Title>
<Title order={2}>{t('vesselTransfer.title')}</Title>
<Tooltip
label="Register a vessel first — there's nothing to transfer yet"
label={t('vesselTransfer.startTransferDisabledTooltip')}
disabled={hasTransferableVessel}
>
<Button
@@ -86,7 +88,7 @@ export function VesselTransferPage() {
disabled={!hasTransferableVessel}
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
>
Start transfer
{t('vesselTransfer.startTransfer')}
</Button>
</Tooltip>
</Group>
@@ -94,7 +96,7 @@ export function VesselTransferPage() {
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Transfers in progress</Title>
<Title order={4}>{t('vesselTransfer.inFlight.title')}</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
@@ -124,7 +126,11 @@ export function VesselTransferPage() {
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
{isDraft
? t('common.continue')
: needsAction
? t('vesselTransfer.inFlight.fix')
: t('vesselTransfer.inFlight.view')}
</Button>
</Group>
</Group>
@@ -136,22 +142,21 @@ export function VesselTransferPage() {
{/* ------------------------------------------------------- vessels */}
<Stack gap="sm">
<Title order={4}>My vessels</Title>
<Title order={4}>{t('vesselTransfer.myVessels.title')}</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 fw={600}>{t('vesselTransfer.myVessels.empty.title')}</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
Ownership can only be transferred for a vessel already on the
register.
{t('vesselTransfer.myVessels.empty.body')}
</Text>
<Button
mt="xs"
variant="light"
onClick={() => navigate('/vessel-registration')}
>
Go to Vessel Registration
{t('vesselTransfer.myVessels.empty.cta')}
</Button>
</Stack>
</Paper>
@@ -160,10 +165,10 @@ export function VesselTransferPage() {
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t('vesselTransfer.table.registrationNumber')}</Table.Th>
<Table.Th>{t('vesselTransfer.table.vessel')}</Table.Th>
<Table.Th>{t('vesselTransfer.table.category')}</Table.Th>
<Table.Th>{t('common.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -186,7 +191,7 @@ export function VesselTransferPage() {
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
{categoryLabels[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
@@ -199,7 +204,7 @@ export function VesselTransferPage() {
<Table.Td>
<Group justify="flex-end">
{canTransfer ? (
<Tooltip label="Start an ownership transfer for this vessel">
<Tooltip label={t('vesselTransfer.table.transferTooltip')}>
<Button
size="compact-xs"
variant="light"
@@ -208,12 +213,12 @@ export function VesselTransferPage() {
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
}
>
Transfer
{t('vesselTransfer.table.transfer')}
</Button>
</Tooltip>
) : (
<Text size="xs" c="dimmed">
Not transferable
{t('vesselTransfer.table.notTransferable')}
</Text>
)}
</Group>

View File

@@ -53,9 +53,11 @@ export const am: Translations = {
seafarerRegistration: 'የባህረኛ ምዝገባ',
exams: 'ፈተናዎች',
seaRecords: 'የባህር መዝገቦቼ',
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀት',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
btc: 'መሠረታዊ ሥልጠና ምስክር ወረቀት',
endorsements: 'ማረጋገጫዎች',
vesselRegistrations: 'የመርከብ ምዝገባ',
@@ -73,6 +75,7 @@ export const am: Translations = {
},
common: {
select: 'ይምረጡ',
back: 'ተመለስ',
continue: 'ቀጥል',
submit: 'አስገባ',
@@ -114,6 +117,56 @@ export const am: Translations = {
dashboard: {
title: 'ዳሽቦርድ',
quickActions: 'ፈጣን ድርጊቶች',
loading: 'ዳሽቦርድ በመጫን ላይ…',
welcome: 'እንኳን ደህና መጡ',
welcomeName: 'እንኳን ደህና መጡ፣ {{name}}',
waitingOnYou: 'እርምጃዎን ይጠብቃል',
noFee: 'ክፍያ የለም',
hero: {
summaryEmpty: 'የባህር ወይም የሎጂስቲክስ ፍቃድ ያመልክቱ እና እስከሚሰጥ ድረስ ይከታተሉት።',
summary: '{{applications}} እና {{licences}} አለዎት።',
applicationsCount_one: '{{count}} ማመልከቻ',
applicationsCount_other: '{{count}} ማመልከቻዎች',
licencesCount_one: '{{count}} ንቁ ፍቃድ',
licencesCount_other: '{{count}} ንቁ ፍቃዶች',
},
actionRequired: {
messages: {
resubmit: 'ገምጋሚው ከመቀጠሉ በፊት እርማት እንዲደረግ ጠይቋል።',
paymentPending: 'ጸድቋል — ምስክር ወረቀቱ ከመሰጠቱ በፊት {{amount}} መከፈል አለበት።',
draft: 'ይህ ማመልከቻ አሁንም ረቂቅ ሲሆን ገና አልገባም።',
},
cta: {
fixNow: 'አሁን አስተካክል',
payNow: 'አሁን ክፈል',
},
},
expiringSoon: {
detail: '{{certificateNumber}} በ{{days}} ቀናት ውስጥ ያበቃል',
},
stats: {
expiringSoon: 'በቅርቡ የሚያበቃ',
},
sections: {
myLicences: {
title: 'ፍቃዶቼ',
},
myApplications: {
title: 'ማመልከቻዎቼ',
empty: 'እስካሁን ምንም ማመልከቻ አላስገቡም። ለመጀመር ከታች ፍቃድ ይምረጡ።',
},
apply: {
title: 'ለፍቃድ ያመልክቱ',
description: 'ኩባንያዎ ከሚሰጠው አገልግሎት ጋር የሚዛመድ ፍቃድ ይምረጡ።',
},
},
getStarted: {
title: 'ይጀምሩ',
body: 'እስካሁን ማመልከቻ አላስገቡም። ኩባንያዎ ከሚሰራው ስራ ጋር የሚዛመድ ፍቃድ ይምረጡ — ማመልከቻዎችዎ እና የተሰጡዎት ፍቃዶች እየገፉ ሲሄዱ እዚህ ይታያሉ።',
},
table: {
application: 'ማመልከቻ',
},
},
applications: {
@@ -157,6 +210,7 @@ export const am: Translations = {
licence: 'ፍቃድ',
applicant: 'አመልካች',
progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
},
actions: {
continue: 'ቀጥል',
@@ -234,9 +288,11 @@ export const am: Translations = {
addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
seafarerBanner: 'የባህረኛ ምዝገባ ለማድረግ የመገለጫ መረጃ ያስፈልጋል።',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
seafarerBanner:
'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
},
profileSections: {
personal: 'የግል መረጃ',
@@ -264,10 +320,30 @@ export const am: Translations = {
verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም',
tabs: {
personal: 'የግል መረጃ',
profile: 'መገለጫ',
address: 'አድራሻ',
operations: 'የስራ ዘርፍ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
maritimeSection: {
title: 'የባህር ሙያ መገለጫ',
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
noProfile: 'ምንም መገለጫ አልተገኘም። መጀመሪያ መገለጫዎን ያጠናቅቁ።',
save: 'መገለጫ አስቀምጥ',
},
addressSection: {
title: 'አድራሻ እና መገናኛ',
subtitle: 'የመታወቂያ ሰነዶችዎ፣ የመገናኛ ዝርዝሮችዎ እና የአደጋ ጊዜ ተጠሪ',
save: 'አድራሻ አስቀምጥ',
},
addressSaved: 'አድራሻ ተቀምጧል',
nameMismatch: 'የመገለጫ ስም ከግል መረጃ ትር ውስጥ ካለው ስም ጋር መዛመድ አለበት።',
languageFull: {
en: 'እንግሊዝኛ (አሜሪካ)',
am: 'አማርኛ',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ',
languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።',
@@ -278,9 +354,41 @@ export const am: Translations = {
dark: 'ጨለማ',
system: 'ሲስተም',
},
sessions: {
title: 'ንቁ የመግቢያ ክፍለ ጊዜዎች',
hint: 'በአሁኑ ሰዓት ወደ መለያዎ የገቡ መሣሪያዎች። የማያውቁትን ይሰርዙ።',
columns: {
device: 'የአይ ፒ አድራሻ',
signedIn: 'የገባበት ጊዜ',
expires: 'የሚያበቃበት',
status: 'ሁኔታ',
actions: 'እርምጃዎች',
},
select: 'ይምረጡ',
selectAll: 'ሁሉንም ክፍለ ጊዜዎች ይምረጡ',
selectRow: 'ከ {{device}} የመጣውን ክፍለ ጊዜ ይምረጡ',
thisDevice: 'ይህ መሣሪያ',
revoke: 'ሰርዝ',
cannotRevokeCurrent: 'ይህ አሁን እየተጠቀሙበት ያለው ክፍለ ጊዜ ነው።',
revokeSelected_one: 'የተመረጠውን {{count}} ሰርዝ',
revokeSelected_other: 'የተመረጡትን {{count}} ሰርዝ',
signOutOthers: 'ከሌሎች ቦታዎች ሁሉ ውጣ',
empty: 'ንቁ ክፍለ ጊዜ የለም።',
confirm: {
title: 'ክፍለ ጊዜ ሰርዝ',
one: 'ከ {{device}} የመጣው ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
selected_one: '{{count}} ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
selected_other: '{{count}} ክፍለ ጊዜዎች ወዲያውኑ ይወጣሉ።',
others: 'ሌሎቹ ክፍለ ጊዜዎች በሙሉ ወዲያውኑ ይወጣሉ።',
unknownDevice: 'ይህ አሁን እየተጠቀሙበት ያለውን መሣሪያ ሊያካትት ይችላል።',
},
revoked_one: '{{count}} ክፍለ ጊዜ ተሰርዟል',
revoked_other: '{{count}} ክፍለ ጊዜዎች ተሰርዘዋል',
},
twoStep: {
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
saved: 'ባለ ሁለት ደረጃ ማረጋገጫ ተዘምኗል',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
@@ -322,6 +430,36 @@ export const am: Translations = {
},
},
profileForm: {
placeholders: {
firstName: 'የመጀመሪያ ስም ያስገቡ',
middleName: 'የአባት ስም ያስገቡ',
lastName: 'የአያት ስም ያስገቡ',
pob: 'ከተማ፣ ክልል',
},
genders: {
MALE: 'ወንድ',
FEMALE: 'ሴት',
},
maritalStatuses: {
SINGLE: 'ያላገባ/ች',
MARRIED: 'ያገባ/ች',
DIVORCED: 'የፈታ/ች',
WIDOWED: 'የሞተበት/ባት',
},
validation: {
professionRequired: 'ሙያዎን ይምረጡ',
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
middleNameMin: 'የአባት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
genderRequired: 'ጾታዎን ይምረጡ',
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
},
},
country: {
select: 'አገር ይምረጡ',
notFound: 'ምንም አገር አልተገኘም',
@@ -422,6 +560,7 @@ export const am: Translations = {
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
nameEnFullNameRequired: "እባክዎ ሙሉ ስምዎን ያስገቡ (የመጀመሪያ፣ የአባት እና የአያት ስም)",
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
@@ -429,7 +568,7 @@ export const am: Translations = {
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
title: "መለያ ይፍጠሩ",
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
nameEnLabel: "ስም (እንግሊዝኛ)",
nameEnLabel: "ሙሉ ስም (እንግሊዝኛ)",
nameEnPlaceholder: "አበበ በቀለ",
nameAmLabel: "ስም (አማርኛ)",
nameAmPlaceholder: "ስም",
@@ -456,4 +595,642 @@ export const am: Translations = {
special: "አንድ ልዩ ምልክት",
},
},
errorBoundary: {
title: 'የሆነ ችግር ተከስቷል',
message: 'ያልተጠበቀ ስህተት ተከስቷል።',
reload: 'ገጹን እንደገና ይጫኑ',
},
featureUnavailable: {
documents: {
title: 'የእኔ ሰነዶች',
description: 'ማዕከላዊ የሰነድ ማከማቻ ገና ከሲስተሙ ጋር አልተገናኘም። ከፈቃድ ማመልከቻ ጋር የሚያስገቧቸው ሰነዶች ከዚያ ማመልከቻ ጋር ተያይዘው ይቀመጣሉ።',
},
medical: {
title: 'የሕክምና ምስክር ወረቀት',
description: 'የሕክምና ምስክር ወረቀቶች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
basicSafetyTraining: {
title: 'መሠረታዊ የደህንነት ስልጠና',
description: 'የመሠረታዊ የደህንነት ስልጠና (BST) መዝገቦች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
seamanBook: {
title: 'የመርከበኛ መጽሐፍ',
description: 'የመርከበኛ መጽሐፍ ማመልከቻዎች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
seamanBookApplication: {
title: 'ለመርከበኛ መጽሐፍ ያመልክቱ',
description: 'የመርከበኛ መጽሐፍ ማመልከቻዎች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
},
notifications: {
title: 'ማሳወቂያዎች',
unread_one: '{{count}} ያልተነበበ',
unread_other: '{{count}} ያልተነበቡ',
allCaughtUp: 'ሁሉንም አይተዋል',
tabs: {
all: 'ሁሉም',
unseen: 'ያልታዩ',
seen: 'የታዩ',
},
empty: {
all: 'እስካሁን ምንም ማሳወቂያ የለም።',
unseen: 'ያልተነበበ ማሳወቂያ የለም።',
seen: 'የተነበበ ማሳወቂያ የለም።',
},
emptyBody: 'ማመልከቻዎ ሲራመድ ይታወቃሉ።',
new: 'አዲስ',
markRead: 'እንደተነበበ ምልክት አድርግ',
loading: 'ማሳወቂያዎች በመጫን ላይ…',
},
onboarding: {
checkingProfile: 'የስራ ማስፈጸሚያ መገለጫ በመፈተሽ ላይ…',
operations: {
title: 'እንደ ምን አይነት ኦፕሬተር ነው የሚሰሩት?',
body: 'ባለስልጣኑ ፍቃድ የሚሰጠው በስራ አይነት (ኦፕሬሽን ሞድ) መሰረት ነው። ኩባንያዎ የሚሰራውን ይንገሩን፤ ማመልከት የሚችሉባቸውን ፍቃዶች እናሳይዎታለን — ይህንን በኋላ ከመገለጫዎ መቀየር ይችላሉ።',
},
},
payments: {
myApplications: 'ማመልከቻዎቼ',
fields: {
amount: 'መጠን',
method: 'የክፍያ ዘዴ',
reference: 'ማጣቀሻ',
paid: 'የተከፈለበት ቀን',
},
check: {
notFoundTitle: 'ይህን ክፍያ ማወቅ አልቻልንም',
notFoundBody: 'የክፍያውን ሁኔታ ለመፈተሽ ማመልከቻውን ከዝርዝርዎ ውስጥ ይክፈቱ።',
stillConfirmingTitle: 'ክፍያዎ በማረጋገጥ ላይ ነው',
stillConfirmingBody: 'ቴሌብር ይህን ክፍያ እስካሁን አላረጋገጠም። ገንዘቡ ከሂሳብዎ ወጥቶ ከሆነ በራስ-ሰር ይተገበራል — እንደገና መክፈል አያስፈልግም።',
checkAgain: 'እንደገና ፈትሽ',
confirmingTitle: 'ክፍያዎ በማረጋገጥ ላይ…',
confirmingBody: 'ይህ በተለምዶ ጥቂት ሰከንዶች ይወስዳል። እባክዎ ይህን ገጽ አይዝጉ።',
},
failure: {
title: 'ክፍያው አልተጠናቀቀም',
defaultReason: 'ክፍያው አልተጠናቀቀም። ምንም ገንዘብ አልተከፈለም።',
unchanged: 'ማመልከቻዎ አልተለወጠም እና በማንኛውም ጊዜ እንደገና መሞከር ይችላሉ።',
},
success: {
title: 'ክፍያ ደርሷል',
body: 'እናመሰግናለን። የፍቃድ ክፍያዎ ተከፍሏል እናም ማመልከቻዎ በመጠናቀቅ ላይ ነው። የምስክር ወረቀትዎ ሲዘጋጅ ይታወቃሉ።',
backToApplications: 'ወደ ማመልከቻዎቼ ተመለስ',
},
},
profileAddress: {
secondaryPhoneNumber: 'ሁለተኛ ስልክ',
postalAddress: 'የፖስታ አድራሻ',
addressSection: 'አድራሻ',
emergencyContactSection: 'የአደጋ ጊዜ ተጠሪ',
emergencyContactOptional: '(አማራጭ)',
contactName: 'የተጠሪ ስም',
contactPhone: 'የተጠሪ ስልክ',
relationship: 'ዝምድና',
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
idTypePlaceholder: 'ይምረጡ',
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
phonePlaceholder: '+251 9XX XXX XXX',
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
postalAddressPlaceholder: 'ፖስታ ሳጥን',
contactNamePlaceholder: 'ሙሉ ስም',
relationshipPlaceholder: 'የትዳር ጓደኛ፣ ወላጅ፣ ወዘተ.',
idTypeOptions: {
NID: 'ብሔራዊ መታወቂያ',
VITAL: 'የልደት/ወሳኝ ኩነት መታወቂያ',
PASSPORT: 'ፓስፖርት',
DRIVERS_LICENSE: 'የመንጃ ፍቃድ',
},
validation: {
idTypeRequired: 'የመታወቂያ ዓይነት ይምረጡ',
idNumberRequired: 'የመታወቂያ ቁጥር ያስገቡ',
nationalityRequired: 'ዜግነት ይምረጡ',
emailInvalid: 'ልክ ያልሆነ ኢሜይል',
},
},
profileOperations: {
title: 'የስራ ዘርፍ',
description: 'ድርጅትዎ በምን ዘርፍ እንደሚሰራ። የትኞቹ ፈቃዶች እንደሚቀርቡልዎት የሚወስነው ይህ ነው — ንግድዎ ሲቀየር ማንኛውም ጊዜ መቀየር ይችላሉ።',
current: 'የአሁኑ',
emptyState: 'እስካሁን የተዋቀሩ የፈቃድ ዓይነቶች የሉም። የሚጠብቁት ካለ EMA ን ያነጋግሩ።',
noneSelectedTitle: 'ምንም የስራ ዘርፍ አልተመረጠም',
noneSelectedBody: 'ምንም ካልተመረጠ ምንም ፈቃድ ለማመልከት አይቀርብልዎትም። ነባር ማመልከቻዎችና የተሰጡ ፈቃዶች አይነኩም።',
lastChanged: 'መጨረሻ የተቀየረው {{date}}',
notSetYet: 'እስካሁን አልተዋቀረም',
discardChanges: 'ለውጦችን ተወው',
saveOperations: 'የስራ ዘርፎችን አስቀምጥ',
removeModalTitle: 'ከስራ ዘርፍዎ ውስጥ ማስወገድ ይፈልጋሉ?',
removingPrefix: 'እያስወገዱ ያሉት፦',
removeConsequence: 'ከዚያ ዓይነት ለአዲስ ማመልከቻ ከእንግዲህ አይቀርብልዎትም። አስቀድመው የቀረቡ ማመልከቻዎች እንደነበሩ ይቀጥላሉ፣ አስቀድመው የተሰጡ ፈቃዶችም ልክ ሆነው ይቆያሉ እንዲሁም ማደስ ይችላሉ።',
removeAndSave: 'አስወግድና አስቀምጥ',
updateSuccessTitle: 'የስራ ዘርፎች ተዘምነዋል',
updateSuccessBody: 'ማመልከት የሚችሉባቸው ፈቃዶች ተዛማጅ እንዲሆኑ ተዘምነዋል።',
updateErrorTitle: 'ማስቀመጥ አልተቻለም',
},
licensing: {
vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
},
msg: {
fileTooLarge: 'ፋይሉ ከ5ሜባ ገደብ በላይ ነው ({{size}})።',
},
documents: {
conditional: 'በሁኔታ ላይ የተመሠረተ',
uploaded: 'ተሰቅሏል',
officerRemark: 'ባለሥልጣን፦ {{name}}',
view: 'ይመልከቱ',
replace: 'ይተኩ',
upload: 'ይስቀሉ',
},
card: {
fallbackName: 'ፍቃድ',
expired: 'ጊዜው ያለፈበት',
expiredOn: 'ጊዜው ያለፈው በ{{date}}',
validUntil: 'እስከ {{date}} ድረስ የፀና',
downloadCertificate: 'የምስክር ወረቀት አውርድ',
renewExpired: 'አድስ — ይህ ፍቃድ ጊዜው አልፎበታል',
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
emptyBody:
'ፍቃዶች የሚቀርቡት እርስዎ በሚሠሩበት የስራ ዘርፍ መሠረት ነው — የጭነት አስተላላፊ፣ የመርከብ ወኪል፣ የተቀናጀ ትራንስፖርት ኦፕሬተር እና የመሳሰሉት። የእርስዎን ይምረጡ፣ ማመልከት የሚችሉባቸው ፍቃዶች እዚህ ይታያሉ።',
setOperations: 'የስራ ዘርፎቼን አዘጋጅ',
browseAll: 'ሁሉንም ፍቃዶች ያስሱ',
showOnlyMine: 'የኔን ብቻ አሳይ',
noneAvailable: 'እስካሁን ምንም የፍቃድ ዓይነት አልቀረበም። የሚጠብቁት ነገር ካለ ባለሥልጣኑን ያነጋግሩ።',
showingAll: 'ከስራ ዘርፎችዎ ውጪ ያሉትንም ጨምሮ ሁሉንም ፍቃዶች በማሳየት ላይ።',
showingMine: 'የተመዘገቡ የስራ ዘርፎችዎን የሚመለከቱ ፍቃዶች ብቻ ታይተዋል።',
otherLicences: 'ሌሎች ፍቃዶች',
otherLicencesDescription: 'ገና ምድብ ያልተሰጣቸው የፍቃድ ዓይነቶች።',
noFee: 'ክፍያ የለም',
capitalTooltip: 'በባንክ ደብዳቤ መረጋገጥ ያለበት ዝቅተኛ ካፒታል',
capitalBadge: 'ካፒታል {{amount}}',
validityBadge: '{{months}} ወራት',
evaluationTooltip: 'በምስክር ወረቀት ፈንታ በባለሥልጣኑ ውሳኔ የሚጠናቀቅ',
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
},
},
certificates: {
title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
fetchFailed: "የምስክር ወረቀቱን ማግኘት አልተቻለም",
eligibility: {
title: "ብቁነት",
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
medicalCurrent: "የአሁኑ የሕክምና የምስክር ወረቀት በመዝገብ ላይ አለ",
medicalRequired: "የአሁኑ የሕክምና የምስክር ወረቀት ያስፈልጋል",
seaTime: "የተረጋገጠ የባህር ጊዜ፦ {{days}} ቀናት (CoC 360, CoP 90 ያስፈልገዋል)",
},
applyCoc: "ለ CoC ያመልክቱ",
applyCop: "ለ CoP ያመልክቱ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
suffix: "ያጠናቅቁ — ያለዚያ የምስክር ወረቀት ማመልከቻዎች ውድቅ ይደረጋሉ።",
},
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
issuedCertificates: "የወጡ የምስክር ወረቀቶች",
emptyIssued: "እስካሁን የወጣ የምስክር ወረቀት የለም።",
columns: {
certificateNumber: "የምስክር ወረቀት ቁጥር",
type: "ዓይነት",
issued: "የወጣበት ቀን",
expires: "የሚያበቃበት ቀን",
licenseStatus: {
ACTIVE: "ንቁ",
EXPIRED: "ጊዜው ያለፈበት",
SUSPENDED: "ታግዷል",
CANCELLED: "ተሰርዟል",
SUPERSEDED: "ተተክቷል",
},
},
},
licenseApplication: {
loading: 'ማመልከቻ በመጫን ላይ…',
fee: 'ክፍያ፡ {{amount}} {{currency}}',
review: 'ግምገማ',
resubmitCorrections: 'ማስተካከያዎችን እንደገና አስገባ',
submitApplication: 'ማመልከቻ አስገባ',
sectionLocked: 'ይህ ክፍል ተቀባይነት አግኝቶ ለዚህ ዙር ተቆልፏል።',
correctionsRequested: {
title: 'ማስተካከያ ተጠይቋል',
onlyListed: 'ከላይ የተዘረዘሩት ነገሮች ብቻ ሊቀየሩ ይችላሉ።',
},
stillMissing: {
title: 'አሁንም የጎደለ',
},
staff: {
addStaffMember: 'የሰራተኛ አባል ጨምር',
fullName: 'ሙሉ ስም',
position: 'የስራ መደብ',
yearsOfExperience: 'የስራ ልምድ ዓመታት',
add: 'ጨምር',
complete: 'ተጠናቅቋል',
requiredCount: '{{count}} ከ{{min}} የሚያስፈልጉ',
eachNeeds: '· እያንዳንዱ የሚያስፈልገው {{items}}',
yearsSuffix: '· {{count}} ዓመታት',
},
notifications: {
startFailed: {
title: 'ማመልከቻውን መጀመር አልተቻለም',
},
saveFailed: {
title: 'ማስቀመጥ አልተቻለም',
},
incomplete: {
title: 'ያልተሟላ',
message: 'ከማስገባትዎ በፊት የተጎሉትን መስኮች ያጠናቅቁ።',
},
incompleteFields_one: 'ለመቀጠል {{count}} አስፈላጊ መስክ ያጠናቅቁ።',
incompleteFields_other: 'ለመቀጠል {{count}} አስፈላጊ መስኮች ያጠናቅቁ።',
resubmitted: {
title: 'እንደገና ገብቷል',
message: 'ማስተካከያዎችዎ ለገምጋሚ ባለስልጣኑ ተልከዋል።',
},
submitted: {
title: 'ማመልከቻ ገብቷል',
message: 'እየገፋ ሲሄድ ይነገርዎታል።',
},
applicationIncomplete: {
title: 'ማመልከቻው ያልተሟላ ነው',
itemsNeedAttention_one: '{{count}} ንጥል አሁንም ትኩረት ይፈልጋል።',
itemsNeedAttention_other: '{{count}} ንጥሎች አሁንም ትኩረት ይፈልጋሉ።',
},
staffIncomplete: {
title: 'ሰራተኛ ያልተሟላ',
message: 'የሚያስፈልጉ፡ {{items}}።',
roleRequired: '{{name}} ({{count}} የሚያስፈልጉ)',
},
documentsMissing: {
title: 'ሰነዶች ይጎድላሉ',
message: 'ስቀል፡ {{items}}።',
andMore: 'እና ሌሎች {{count}}',
},
},
},
exams: {
title: 'ፈተናዎች',
openSessions: 'ክፍት ፈተናዎች',
noOpenSessions: 'ለምዝገባ ክፍት የሆነ መጪ ፈተና የለም።',
registered: 'ተመዝግቧል',
register: 'ይመዝገቡ',
myRegistrations: 'የእኔ ምዝገባዎች',
myResults: 'የእኔ ውጤቶች',
noRegistrations: 'እስካሁን የፈተና ምዝገባ የለም።',
noResults:
'እስካሁን የታተመ ውጤት የለም። ባለሥልጣኑ ካጸደቀና ካሳተመ በኋላ ውጤቶች እዚህ ይታያሉ።',
loading: 'የፈተና መርሃ ግብር በመጫን ላይ…',
notify: {
registered: 'ተመዝግበዋል — የመግቢያ ቁጥር {{admissionNumber}}',
admissionNumberPending: 'ወጥቷል',
registerFailed: 'መመዝገብ አልተቻለም',
seafarerRequired: 'ፈተና ለመቀመጥ ንቁ የመርከበኛ ምዝገባ ያስፈልጋል።',
alreadyRegistered: 'ለዚህ ፈተና አስቀድመው ተመዝግበዋል።',
alreadyPassed: 'ይህን ትምህርት አስቀድመው አልፈዋል — ድጋሚ መፈተን አያስፈልግም።',
slipFailed: 'የመግቢያ ወረቀት ማዘጋጀት አልተቻለም',
appealSubmitted: 'የይግባኝ {{appealNumber}} ቀርቧል',
appealFailed: 'ይግባኝ ማስገባት አልተቻለም',
appealWindowClosed:
'የይግባኝ ማቅረቢያ ጊዜው (ከታተመበት ቀን ጀምሮ {{days}} ቀናት) አልፏል።',
appealAlreadyOpen: 'በዚህ ውጤት ላይ ይግባኝ አስቀድሞ በመታየት ላይ ነው።',
},
appealModal: {
title: 'የዚህን ውጤት ግምገማ ይጠይቁ',
body: 'በ{{examTitle}} ውጤት አሰጣጥ ወይም አስተዳደር ላይ ስህተት ነው ብለው የሚያምኑትን ያብራሩ። ይግባኝ ከታተመበት ቀን ጀምሮ በ14 ቀናት ውስጥ መቅረብ አለበት።',
defaultExamTitle: 'ይህ ፈተና',
reasonLabel: 'የይግባኝ ምክንያት',
submit: 'ይግባኝ ያስገቡ',
},
columns: {
admission: 'የመግቢያ ቁጥር',
examination: 'ፈተና',
date: 'ቀን',
venue: 'ቦታ',
attempt: 'ሙከራ',
attendance: 'መገኘት',
slip: 'ወረቀት',
published: 'የታተመበት ቀን',
score: 'ውጤት',
outcome: 'ውጤት',
appeal: 'ይግባኝ',
retake: 'ድጋሚ · {{n}}',
firstSitting: 'የመጀመሪያ ሙከራ',
exam: 'ፈተና',
completed: 'ተጠናቋል',
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
ABSENT: 'አልተገኘም',
LATE: 'ዘግይቷል',
WITHDRAWN: 'ወጥቷል',
DISQUALIFIED: 'ታግዷል',
},
outcomeStatus: {
PASSED: 'አልፏል',
FAILED: 'አልተሳካም',
},
appealStatus: {
SUBMITTED: 'ገብቷል',
UNDER_REVIEW: 'በግምገማ ላይ',
UPHELD: 'ጸድቋል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
},
endorsement: {
title: "የእኔ ማረጋገጫዎች",
loading: "ማረጋገጫዎች በመጫን ላይ…",
fetchFailed: "ማረጋገጫውን ማግኘት አልተቻለም",
eligibility: {
title: "ብቁነት",
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
},
endorseCoc: "CoC ያረጋግጡ",
endorseGoc: "GOC ያረጋግጡ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
suffix: "ያጠናቅቁ — ያለዚያ የማረጋገጫ ማመልከቻዎች ውድቅ ይደረጋሉ።",
},
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
issuedEndorsements: "የወጡ ማረጋገጫዎች",
emptyIssued: "እስካሁን የወጣ ማረጋገጫ የለም።",
columns: {
certificateNumber: "የምስክር ወረቀት ቁጥር",
type: "ዓይነት",
issued: "የወጣበት ቀን",
expires: "የሚያበቃበት ቀን",
licenseStatus: {
ACTIVE: "ንቁ",
EXPIRED: "ጊዜው ያለፈበት",
SUSPENDED: "ታግዷል",
CANCELLED: "ተሰርዟል",
SUPERSEDED: "ተተክቷል",
},
},
},
seafarer: {
title: 'የባህረኛ ምዝገባ',
status: {
ACTIVE: 'ንቁ',
PENDING: 'በመጠባበቅ ላይ',
SUSPENDED: 'ታግዷል',
INACTIVE: 'ንቁ ያልሆነ',
},
departments: {
DECK: 'ዴክ',
ENGINE: 'ምህንድስና',
CATERING: 'ኬተሪንግ',
},
registered: {
badgeTitle: 'የተመዘገበ ባህረኛ',
badgeSubtitle: 'ከኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ጋር ያለዎት ይፋዊ የባህረኛ መገለጫ።',
seafarerNumber: 'የባህረኛ ቁጥር',
department: 'ክፍል',
suspendedAlert: 'መገለጫዎ ታግዷል፦ {{reason}}',
recordsTitle: 'የባህር አገልግሎትና የሕክምና መዝገቦች',
recordsSubtitle: 'የባህር አገልግሎት ታሪክዎንና የሕክምና የምስክር ወረቀቶችዎን ወቅታዊ ያድርጉ — የምስክር ወረቀትና የባህረኛ መጽሐፍ ማመልከቻዎች ከእነሱ ይመዘናሉ።',
myRecords: 'መዝገቦቼ',
},
inFlight: {
subtitle: 'የገቡ ምዝገባዎች በ EMA የምዝገባ ባለሙያ ይገመገማሉ፤ ስለ እያንዳንዱ ውሳኔ ይነገርዎታል።',
needsAction: 'የምዝገባ ባለሙያው ማስተካከያ ጠይቋል። ምን መስተካከል እንዳለበት በትክክል ለማየት ማመልከቻውን ይክፈቱ።',
continueRegistration: 'ምዝገባ ይቀጥሉ',
fixAndResubmit: 'አስተካክለው እንደገና ያስገቡ',
viewApplication: 'ማመልከቻ ይመልከቱ',
},
notStarted: {
rejectedTitle: 'ቀደም ያለ ምዝገባ ውድቅ ተደርጓል',
rejectedDefault: 'ቀደም ያለ ምዝገባዎ ውድቅ ተደርጓል። እንደገና መመዝገብ ይችላሉ።',
heading: 'እንደ ባህረኛ ይመዝገቡ',
body: 'መጽደቅ ልዩ የባህረኛ ቁጥር ያለው ይፋዊ የባህረኛ መገለጫ ይፈጥርልዎታል — እያንዳንዱ የባሕር አገልግሎት የሚገነባበት ማንነት።',
needsTitle: 'የሚያስፈልግዎት፦',
checklist: {
photo: 'የፓስፖርት መጠን ያለው ፎቶግራፍ',
id: 'ብሔራዊ መታወቂያዎ (ፋይዳ) ወይም የቀበሌ መታወቂያ',
certificate: 'የትምህርት ማስረጃዎ',
medical: 'የሕክምና ብቁነት የምስክር ወረቀትና ፓስፖርት፣ አስቀድመው ካሉዎት',
},
start: 'ምዝገባ ይጀምሩ',
},
},
seaRecords: {
title: 'የባህር መዝገቦቼ',
pageIntro: 'እዚህ የሚያክሏቸው መዝገቦች ለ EMA ማረጋገጫ ይላካሉ። ከተረጋገጡ በኋላ ይዘጋሉ እንዲሁም ለምስክር ወረቀት ብቁነት ይቆጠራሉ።',
tabs: {
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀቶች',
},
evidence: {
title: 'ማስረጃ',
none: 'እስካሁን ምንም ማስረጃ አልተሰቀለም።',
upload: 'ማስረጃ ስቀል',
uploaded: 'ማስረጃ ተሰቅሏል',
},
seaService: {
description: 'በመርከብ ላይ የተደረገ እያንዳንዱ ተሳትፎ፣ ከማስረጃው ጋር። የተረጋገጡ መዝገቦች ለምስክር ወረቀት ብቁነት ይውላሉ።',
approvedSeaTime: 'የጸደቀ የባህር ጊዜ፦ {{days}} ቀናት',
add: 'የባህር አገልግሎት ጨምር',
empty: 'እስካሁን የባህር አገልግሎት መዝገብ የለም።',
tableName: 'የባህር አገልግሎት',
modal: {
editTitle: 'የባህር አገልግሎት አርትዕ',
addTitle: 'የባህር አገልግሎት ጨምር',
},
fields: {
vesselName: 'የመርከብ ስም',
imoNumber: 'የ IMO ቁጥር',
vesselType: 'የመርከብ አይነት',
flagState: 'የባንዲራ ሀገር',
grossTonnage: 'ጠቅላላ ቶኔጅ',
rank: 'ማዕረግ / ኃላፊነት',
engagementDate: 'የተቀጠሩበት ቀን',
dischargeDate: 'የተሰናበቱበት ቀን',
duties: 'ተግባራት',
},
addRecord: 'መዝገብ ጨምር',
updated: 'የባህር አገልግሎት መዝገብ ተዘምኗል',
added: 'የባህር አገልግሎት መዝገብ ታክሏል',
saveFailed: 'መዝገቡን ማስቀመጥ አልተቻለም',
withdrawn: 'መዝገብ ተነስቷል',
deleteFailed: 'መዝገቡን መሰረዝ አልተቻለም',
},
medical: {
description: 'የ STCW የሕክምና ብቁነት የምስክር ወረቀቶች። ጊዜው ያለፈበት የምስክር ወረቀት እሱን የሚያስፈልጋቸውን አዳዲስ ማመልከቻዎች ያግዳል።',
add: 'የምስክር ወረቀት ጨምር',
empty: 'እስካሁን የሕክምና የምስክር ወረቀት የለም።',
tableName: 'የሕክምና የምስክር ወረቀቶች',
modal: {
editTitle: 'የሕክምና የምስክር ወረቀት አርትዕ',
addTitle: 'የሕክምና የምስክር ወረቀት ጨምር',
},
fields: {
issuerName: 'የሰጠው ክሊኒክ / ሐኪም',
certificateNumber: 'የምስክር ወረቀት ቁጥር',
issueDate: 'የተሰጠበት ቀን',
expiryDate: 'የሚያበቃበት ቀን',
fitnessOutcome: 'የብቁነት ውጤት',
restrictions: 'ገደቦች',
},
updated: 'የሕክምና የምስክር ወረቀት ተዘምኗል',
added: 'የሕክምና የምስክር ወረቀት ታክሏል',
saveFailed: 'የምስክር ወረቀቱን ማስቀመጥ አልተቻለም',
withdrawn: 'የምስክር ወረቀት ተነስቷል',
deleteFailed: 'የምስክር ወረቀቱን መሰረዝ አልተቻለም',
},
columns: {
vessel: 'መርከብ',
imo: 'IMO {{number}}',
rank: 'ማዕረግ',
from: 'ከ',
to: 'እስከ',
issuer: 'ሰጪ',
certNumber: '№ {{number}}',
issued: 'የተሰጠበት',
expires: 'የሚያበቃበት',
expired: 'ጊዜው አልፏል',
fitness: 'ብቁነት',
fitnessOptions: {
FIT: 'ብቁ',
FIT_WITH_RESTRICTIONS: 'በገደብ ብቁ',
UNFIT: 'ብቁ ያልሆነ',
},
recordStatus: {
SUBMITTED: 'ገብቷል',
VERIFIED: 'ተረጋግጧል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
actions: {
evidence: 'ማስረጃ',
scanEvidence: 'ስካን / ማስረጃ',
edit: 'አርትዕ',
delete: 'ሰርዝ',
frozen: 'የተረጋገጡ መዝገቦች ተዘግተዋል',
certificatesFrozen: 'የተረጋገጡ የምስክር ወረቀቶች ተዘግተዋል',
},
},
vesselRegistration: {
title: 'የመርከብ ምዝገባ',
registerButton: 'መርከብ ይመዝግቡ',
suspendedAlert:
'የታገደ መርከብ መንቀሳቀስ አይችልም። ስለ ዳግም ማቋቋም የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣንን ያግኙ።',
inFlight: {
title: 'በሂደት ላይ ያሉ ምዝገባዎች',
renewalBadge: 'እድሳት',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'የውስጥ ውሃ መስመር ወይም የባህር ማዕድ መርከብ ይመዝግቡ። ማጽደቅ የምዝገባ የምስክር ወረቀት ያወጣል እንዲሁም መርከቧን በብሔራዊ መዝገብ ውስጥ ያስገባል።',
cta: 'ምዝገባ ጀምር',
},
},
notify: {
comingSoon: 'የማሻሻያ እና የምስክር ወረቀት ድግግሞሽ አገልግሎቶች በሚቀጥለው ስሪት ይመጣሉ።',
},
incident: {
modalTitle: 'አደጋ ሪፖርት አድርግ — {{vesselName}}',
dateLabel: 'የተከሰተበት ቀን',
locationLabel: 'ቦታ',
descriptionLabel: 'የተከሰተው ነገር',
submit: 'አደጋ መዝግብ',
recorded: 'አደጋው ተመዝግቧል',
recordFailed: 'አደጋውን መመዝገብ አልተቻለም',
},
certificate: {
fetchFailed: 'የምስክር ወረቀቱን ማግኘት አልተቻለም',
},
renewal: {
startFailed: 'እድሳቱን መጀመር አልተቻለም',
},
columns: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
certificate: 'የምስክር ወረቀት',
expiresIn: 'በ{{count}} ቀን ውስጥ ያበቃል',
renew: 'አድስ',
renewTooltip: 'ምዝገባውን አድስ',
certificateTooltip: 'የምስክር ወረቀት አውርድ',
incident: 'አደጋ',
incidentTooltip: 'አደጋ / ችግር ሪፖርት አድርግ',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
status: {
notFound: 'ምዝገባ አልተገኘም።',
title: 'የምዝገባ ሁኔታ',
submitted: 'የገባው {{date}}',
officerRemarks: 'የመኮንን አስተያየት',
pending: 'በመጠባበቅ ላይ',
resubmit: 'ማመልከቻ እንደገና አስገባ',
certificatesTitle: 'የምስክር ወረቀቶች',
certificateNumber: 'የምስክር ወረቀት ቁጥር {{number}} — የወጣበት {{date}}',
downloadedCount_one: '{{count}} ጊዜ ወርዷል',
downloadedCount_other: '{{count}} ጊዜያት ወርዷል',
downloadedNotify: '{{certName}} ወርዷል።',
renewalOverdue: 'ምዝገባው እድሳት ጊዜው አልፎበታል።',
renewalOverdueWithExpiry: 'ምዝገባው እድሳት ጊዜው አልፎበታል — የሚያበቃው {{date}}።',
renewalDueSoon: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል።',
renewalDueSoonWithExpiry: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል — የሚያበቃው {{date}}።',
},
},
vesselTransfer: {
title: 'የባለቤትነት ዝውውር',
startTransfer: 'ዝውውር ጀምር',
startTransferDisabledTooltip: 'መጀመሪያ መርከብ ይመዝግቡ — እስካሁን የሚዛወር ነገር የለም',
inFlight: {
title: 'በሂደት ላይ ያሉ ዝውውሮች',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'ባለቤትነት ሊዛወር የሚችለው ቀድሞ በመዝገብ ውስጥ ላለ መርከብ ብቻ ነው።',
cta: 'ወደ የመርከብ ምዝገባ ይሂዱ',
},
},
table: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
transfer: 'አዛውር',
transferTooltip: 'ለዚህ መርከብ የባለቤትነት ዝውውር ጀምር',
notTransferable: 'ሊዛወር አይችልም',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
},
};

View File

@@ -42,6 +42,8 @@ export const en = {
seafarerRegistration: 'Seafarer Registration',
exams: 'Examinations',
seaRecords: 'My Sea Records',
seaService: 'Sea Service',
medical: 'Medical Certificate',
myApplication: 'My Application',
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
@@ -54,7 +56,7 @@ export const en = {
mtoLicense: 'MTO License',
waiver: 'Waiver',
certificates: 'Certificates',
seamanBook: 'Seaman Book',
seamanBook: 'SeamanBook and BTC',
btc: 'Basic Training Certificate',
endorsements: 'Endorsements',
vesselRegistrations: 'Vessel Registration',
@@ -72,6 +74,7 @@ export const en = {
},
common: {
select: 'Select',
back: 'Back',
continue: 'Continue',
submit: 'Submit',
@@ -113,6 +116,56 @@ export const en = {
dashboard: {
title: 'Dashboard',
quickActions: 'Quick actions',
loading: 'Loading dashboard…',
welcome: 'Welcome back',
welcomeName: 'Welcome back, {{name}}',
waitingOnYou: 'Waiting on you',
noFee: 'No fee',
hero: {
summaryEmpty: 'Apply for a maritime or logistics licence and track it through to issue.',
summary: 'You have {{applications}} and {{licences}}.',
applicationsCount_one: '{{count}} application',
applicationsCount_other: '{{count}} applications',
licencesCount_one: '{{count}} active licence',
licencesCount_other: '{{count}} active licences',
},
actionRequired: {
messages: {
resubmit: 'A reviewer asked for corrections before this can proceed.',
paymentPending: 'Approved — {{amount}} due before the certificate is issued.',
draft: 'This application is still a draft and has not been filed.',
},
cta: {
fixNow: 'Fix now',
payNow: 'Pay now',
},
},
expiringSoon: {
detail: '{{certificateNumber}} expires in {{days}} days',
},
stats: {
expiringSoon: 'Expiring soon',
},
sections: {
myLicences: {
title: 'My licences',
},
myApplications: {
title: 'My applications',
empty: 'You have not filed any applications yet. Pick a licence below to get started.',
},
apply: {
title: 'Apply for a licence',
description: 'Choose the licence that matches the service your company provides.',
},
},
getStarted: {
title: 'Get started',
body: 'You have not filed an application yet. Choose the licence that matches what your company does — your applications and the licences issued to you will appear here as you go.',
},
table: {
application: 'Application',
},
},
applications: {
@@ -156,6 +209,7 @@ export const en = {
licence: 'Licence',
applicant: 'Applicant',
progress: 'Progress',
applicationNumber: 'Application №',
},
actions: {
continue: 'Continue',
@@ -234,8 +288,9 @@ export const en = {
viewProfile: 'View full profile',
seafarerReason:
'Seafarer registration is built from your profile — these details fill it in for you.',
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
seafarerBanner: 'Profile details are needed for seafarer registration.',
seafarerBanner:
'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.',
checkingProfile: 'Checking seafarer profile…',
},
profileSections: {
@@ -264,10 +319,30 @@ export const en = {
verified: 'Verified',
unverified: 'Unverified',
tabs: {
personal: 'Personal',
profile: 'Profile',
address: 'Address',
operations: 'Operations',
security: 'Security',
preferences: 'Preferences',
},
maritimeSection: {
title: 'Maritime Profile',
subtitle: 'Your professional maritime details',
noProfile: 'No profile found. Complete your profile setup first.',
save: 'Save Profile',
},
addressSection: {
title: 'Address & Contact',
subtitle: 'Your identity documents, contact details and emergency contact',
save: 'Save Address',
},
addressSaved: 'Address saved',
nameMismatch: 'Profile name must match the name in the Personal tab.',
languageFull: {
en: 'English (United States)',
am: 'Amharic',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the EMA portal.',
@@ -278,9 +353,41 @@ export const en = {
dark: 'Dark',
system: 'System',
},
sessions: {
title: 'Active sessions',
hint: 'Devices currently signed in to your account. Revoke any you do not recognise.',
columns: {
device: 'IP address',
signedIn: 'Signed in',
expires: 'Expires',
status: 'Status',
actions: 'Actions',
},
select: 'Select',
selectAll: 'Select all sessions',
selectRow: 'Select session from {{device}}',
thisDevice: 'This device',
revoke: 'Revoke',
cannotRevokeCurrent: 'This is the session you are using now.',
revokeSelected_one: 'Revoke {{count}} selected',
revokeSelected_other: 'Revoke {{count}} selected',
signOutOthers: 'Sign out everywhere else',
empty: 'No active sessions.',
confirm: {
title: 'Revoke session',
one: 'The session from {{device}} will be signed out immediately.',
selected_one: '{{count}} session will be signed out immediately.',
selected_other: '{{count}} sessions will be signed out immediately.',
others: 'Every other session will be signed out immediately.',
unknownDevice: 'This may include the device you are using now.',
},
revoked_one: '{{count}} session revoked',
revoked_other: '{{count}} sessions revoked',
},
twoStep: {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
saved: 'Two-step verification updated',
},
notifications: {
title: 'Email notifications',
@@ -322,6 +429,36 @@ export const en = {
},
},
profileForm: {
placeholders: {
firstName: 'Enter first name',
middleName: 'Enter middle name',
lastName: 'Enter last name',
pob: 'City, Region',
},
genders: {
MALE: 'Male',
FEMALE: 'Female',
},
maritalStatuses: {
SINGLE: 'Single',
MARRIED: 'Married',
DIVORCED: 'Divorced',
WIDOWED: 'Widowed',
},
validation: {
professionRequired: 'Select your profession',
firstNameMin: 'First name must be at least 3 characters',
middleNameMin: 'Middle name must be at least 3 characters',
lastNameMin: 'Last name must be at least 3 characters',
genderRequired: 'Select your gender',
dobRequired: 'Select your date of birth',
dobMinAge: 'You must be at least 18 years old',
maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name',
},
},
country: {
select: 'Select a country',
notFound: 'No countries found',
@@ -422,6 +559,7 @@ export const en = {
signup: {
usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required',
nameEnFullNameRequired: 'Please enter your full name (first, middle, and last)',
phoneRequired: 'Phone number is required',
confirmPasswordRequired: 'Confirm your password',
passwordsDontMatch: 'Passwords do not match',
@@ -429,7 +567,7 @@ export const en = {
brandSubtitle: 'Create your account to access {{appName}} features.',
title: 'Create account',
subtitle: 'It only takes a minute to get started.',
nameEnLabel: 'Name (English)',
nameEnLabel: 'Full name (English)',
nameEnPlaceholder: 'Abebe Bekele',
nameAmLabel: 'Name (Amharic)',
nameAmPlaceholder: 'ስም',
@@ -456,6 +594,646 @@ export const en = {
special: 'One special character',
},
},
errorBoundary: {
title: 'Something went wrong',
message: 'An unexpected error occurred.',
reload: 'Reload page',
},
featureUnavailable: {
documents: {
title: 'My documents',
description: 'A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application.',
},
medical: {
title: 'Medical certificate',
description: 'Medical certificates are not connected to the backend yet.',
},
basicSafetyTraining: {
title: 'Basic Safety Training',
description: 'BST records are not connected to the backend yet.',
},
seamanBook: {
title: 'Seaman Book',
description: 'Seaman Book applications are not connected to the backend yet.',
},
seamanBookApplication: {
title: 'Apply for a Seaman Book',
description: 'Seaman Book applications are not connected to the backend yet.',
},
},
notifications: {
title: 'Notifications',
unread_one: '{{count}} unread',
unread_other: '{{count}} unread',
allCaughtUp: 'You are all caught up',
tabs: {
all: 'All',
unseen: 'Unseen',
seen: 'Seen',
},
empty: {
all: 'No notifications yet.',
unseen: 'Nothing unread.',
seen: 'No read notifications.',
},
emptyBody: 'You will be notified as your applications progress.',
new: 'new',
markRead: 'Mark read',
loading: 'Loading Notifications…',
},
onboarding: {
checkingProfile: 'Checking operations profile…',
operations: {
title: 'What do you operate as?',
body: 'The Authority licenses by mode of operation. Tell us what your company does and we will show you the licences you can apply for — you can change this later from your profile.',
},
},
payments: {
myApplications: 'My applications',
fields: {
amount: 'Amount',
method: 'Method',
reference: 'Reference',
paid: 'Paid',
},
check: {
notFoundTitle: 'We could not identify this payment',
notFoundBody: 'Open the application from your list to check its payment status.',
stillConfirmingTitle: 'Still confirming your payment',
stillConfirmingBody: 'Telebirr has not confirmed this payment yet. If the money has left your account it will be applied automatically — there is no need to pay again.',
checkAgain: 'Check again',
confirmingTitle: 'Confirming your payment…',
confirmingBody: 'This usually takes a few seconds. Please do not close this page.',
},
failure: {
title: 'Payment not completed',
defaultReason: 'The payment was not completed. Nothing has been charged.',
unchanged: 'Your application is unchanged and you can try again at any time.',
},
success: {
title: 'Payment received',
body: 'Thank you. Your licence fee has been paid and your application is being finalised. You will be notified when your certificate is ready.',
backToApplications: 'Back to my applications',
},
},
profileAddress: {
secondaryPhoneNumber: 'Secondary Phone',
postalAddress: 'Postal Address',
addressSection: 'Address',
emergencyContactSection: 'Emergency Contact',
emergencyContactOptional: '(optional)',
contactName: 'Contact Name',
contactPhone: 'Contact Phone',
relationship: 'Relationship',
accountManagedHint: 'From your account, edit it in the Personal tab',
idTypePlaceholder: 'Select',
idNumberPlaceholder: 'Enter ID number',
phonePlaceholder: '+251 9XX XXX XXX',
streetAddressPlaceholder: 'Street name, house number',
postalAddressPlaceholder: 'P.O. Box',
contactNamePlaceholder: 'Full name',
relationshipPlaceholder: 'Spouse, Parent, etc.',
idTypeOptions: {
NID: 'National Id',
VITAL: 'Vital ID',
PASSPORT: 'Passport',
DRIVERS_LICENSE: "Driver's License",
},
validation: {
idTypeRequired: 'Select ID type',
idNumberRequired: 'Enter ID number',
nationalityRequired: 'Select nationality',
emailInvalid: 'Invalid email',
},
},
profileOperations: {
title: 'Mode of operation',
description: 'What your company operates as. This decides which licences you are offered — you can change it whenever your business changes.',
current: 'Current',
emptyState: 'No licence types are configured yet. Contact EMA if you were expecting one.',
noneSelectedTitle: 'No operations selected',
noneSelectedBody: 'With none selected you will not be offered any licence to apply for. Existing applications and issued licences are unaffected.',
lastChanged: 'Last changed {{date}}',
notSetYet: 'Not set yet',
discardChanges: 'Discard changes',
saveOperations: 'Save operations',
removeModalTitle: 'Remove from your operations?',
removingPrefix: 'You are removing',
removeConsequence: 'You will no longer be offered a new application of that type. Applications already filed carry on as they are, and licences already issued to you stay valid and can still be renewed.',
removeAndSave: 'Remove and save',
updateSuccessTitle: 'Operations updated',
updateSuccessBody: 'The licences you can apply for have been updated to match.',
updateErrorTitle: 'Could not save',
},
licensing: {
vesselPicker: {
placeholder: 'Select a registered vessel',
},
msg: {
fileTooLarge: 'File exceeds 5MB limit ({{size}}).',
},
documents: {
conditional: 'conditional',
uploaded: 'uploaded',
officerRemark: 'Officer: {{name}}',
view: 'View',
replace: 'Replace',
upload: 'Upload',
},
card: {
fallbackName: 'Licence',
expired: 'Expired',
expiredOn: 'Expired on {{date}}',
validUntil: 'Valid until {{date}}',
downloadCertificate: 'Download certificate',
renewExpired: 'Renew — this licence has expired',
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
emptyBody:
'Licences are offered against your mode of operation — freight forwarder, shipping agent, multimodal transport operator and so on. Choose yours and the licences you can apply for appear here.',
setOperations: 'Set my operations',
browseAll: 'Browse all licences',
showOnlyMine: 'Show only mine',
noneAvailable: 'No licence types are available yet. Contact EMA if you were expecting one.',
showingAll: 'Showing every licence, including ones outside your operations.',
showingMine: 'Only licences matching your declared operations are shown.',
otherLicences: 'Other licences',
otherLicencesDescription: 'Licence types that have not been assigned a category.',
noFee: 'No fee',
capitalTooltip: 'Minimum capital that must be evidenced by a bank letter',
capitalBadge: 'Capital {{amount}}',
validityBadge: '{{months}} months',
evaluationTooltip: 'Concludes with an EMA decision rather than a certificate',
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
},
},
certificates: {
title: 'My Certificates',
loading: 'Loading Certificates…',
fetchFailed: 'Could not fetch certificate',
eligibility: {
title: 'Eligibility',
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
medicalCurrent: 'Current medical certificate on file',
medicalRequired: 'A current medical certificate is required',
seaTime: 'Verified sea time: {{days}} days (CoC needs 360, CoP 90)',
},
applyCoc: 'Apply for CoC',
applyCop: 'Apply for CoP',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
suffix: 'first — certificate applications are refused without it.',
},
inProgress: 'Applications in progress',
issuedCertificates: 'Issued certificates',
emptyIssued: 'No certificates issued yet.',
columns: {
certificateNumber: 'Certificate №',
type: 'Type',
issued: 'Issued',
expires: 'Expires',
licenseStatus: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Superseded',
},
},
},
licenseApplication: {
loading: 'Loading Application…',
fee: 'Fee: {{amount}} {{currency}}',
review: 'Review',
resubmitCorrections: 'Resubmit corrections',
submitApplication: 'Submit application',
sectionLocked: 'This section was accepted and is locked for this round.',
correctionsRequested: {
title: 'Corrections requested',
onlyListed: 'Only the items listed above can be changed.',
},
stillMissing: {
title: 'Still missing',
},
staff: {
addStaffMember: 'Add staff member',
fullName: 'Full name',
position: 'Position',
yearsOfExperience: 'Years of experience',
add: 'Add',
complete: 'complete',
requiredCount: '{{count}} of {{min}} required',
eachNeeds: '· each needs {{items}}',
yearsSuffix: '· {{count}} yrs',
},
notifications: {
startFailed: {
title: 'Could not start application',
},
saveFailed: {
title: 'Could not save',
},
incomplete: {
title: 'Incomplete',
message: 'Complete the highlighted fields before submitting.',
},
incompleteFields_one: 'Complete {{count}} required field to continue.',
incompleteFields_other: 'Complete {{count}} required fields to continue.',
resubmitted: {
title: 'Resubmitted',
message: 'Your corrections were sent back to the reviewing officer.',
},
submitted: {
title: 'Application submitted',
message: 'You will be notified as it progresses.',
},
applicationIncomplete: {
title: 'Application incomplete',
itemsNeedAttention_one: '{{count}} item still needs attention.',
itemsNeedAttention_other: '{{count}} items still need attention.',
},
staffIncomplete: {
title: 'Staff incomplete',
message: 'Still needed: {{items}}.',
roleRequired: '{{name}} ({{count}} required)',
},
documentsMissing: {
title: 'Documents missing',
message: 'Upload: {{items}}.',
andMore: 'and {{count}} more',
},
},
},
exams: {
title: 'Examinations',
openSessions: 'Open sessions',
noOpenSessions: 'No upcoming sessions are open for registration.',
registered: 'Registered',
register: 'Register',
myRegistrations: 'My registrations',
myResults: 'My results',
noRegistrations: 'No exam registrations yet.',
noResults:
'No results have been published yet. Marks appear here once the authority approves and publishes them.',
loading: 'Loading Exam Schedule…',
notify: {
registered: 'Registered — admission number {{admissionNumber}}',
admissionNumberPending: 'issued',
registerFailed: 'Could not register',
seafarerRequired:
'An active seafarer registration is required to sit examinations.',
alreadyRegistered: 'You are already registered for this session.',
alreadyPassed:
'You have already passed this subject — a resit is not needed.',
slipFailed: 'Could not generate the admission slip',
appealSubmitted: 'Appeal {{appealNumber}} submitted',
appealFailed: 'Could not submit the appeal',
appealWindowClosed:
'The appeal window ({{days}} days from publication) has closed.',
appealAlreadyOpen: 'An appeal on this result is already being considered.',
},
appealModal: {
title: 'Request a review of this result',
body: 'Explain what you believe went wrong with the marking or the administration of {{examTitle}}. Appeals must be lodged within 14 days of publication.',
defaultExamTitle: 'this examination',
reasonLabel: 'Grounds for appeal',
submit: 'Submit appeal',
},
columns: {
admission: 'Admission №',
examination: 'Examination',
date: 'Date',
venue: 'Venue',
attempt: 'Attempt',
attendance: 'Attendance',
slip: 'Slip',
published: 'Published',
score: 'Score',
outcome: 'Outcome',
appeal: 'Appeal',
retake: 'Retake · {{n}}',
firstSitting: 'First sitting',
exam: 'Exam',
completed: 'Completed',
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',
ABSENT: 'Absent',
LATE: 'Late',
WITHDRAWN: 'Withdrawn',
DISQUALIFIED: 'Disqualified',
},
outcomeStatus: {
PASSED: 'Passed',
FAILED: 'Failed',
},
appealStatus: {
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under review',
UPHELD: 'Upheld',
REJECTED: 'Rejected',
},
},
},
endorsement: {
title: 'My Endorsements',
loading: 'Loading Endorsements…',
fetchFailed: 'Could not fetch endorsement',
eligibility: {
title: 'Eligibility',
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
},
endorseCoc: 'Endorse a CoC',
endorseGoc: 'Endorse a GOC',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
suffix: 'first — endorsement applications are refused without it.',
},
inProgress: 'Applications in progress',
issuedEndorsements: 'Issued endorsements',
emptyIssued: 'No endorsements issued yet.',
columns: {
certificateNumber: 'Certificate №',
type: 'Type',
issued: 'Issued',
expires: 'Expires',
licenseStatus: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Superseded',
},
},
},
seafarer: {
title: 'Seafarer Registration',
status: {
ACTIVE: 'Active',
PENDING: 'Pending',
SUSPENDED: 'Suspended',
INACTIVE: 'Inactive',
},
departments: {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
},
registered: {
badgeTitle: 'Registered Seafarer',
badgeSubtitle: 'Your official seafarer profile with the Ethiopian Maritime Authority.',
seafarerNumber: 'Seafarer Number',
department: 'Department',
suspendedAlert: 'Your profile is suspended: {{reason}}',
recordsTitle: 'Sea service & medical records',
recordsSubtitle: 'Keep your sea-service history and medical certificates up to date — certificate and seaman-book applications draw on them.',
myRecords: 'My records',
},
inFlight: {
subtitle: 'Submitted registrations are reviewed by an EMA registration officer; you will be notified of every decision.',
needsAction: 'The registration officer asked for corrections. Open the application to see exactly what needs fixing.',
continueRegistration: 'Continue registration',
fixAndResubmit: 'Fix and resubmit',
viewApplication: 'View application',
},
notStarted: {
rejectedTitle: 'Previous registration rejected',
rejectedDefault: 'Your previous registration was rejected. You may register again.',
heading: 'Register as a seafarer',
body: 'Approval creates your official seafarer profile with a unique seafarer number — the identity every maritime service builds on.',
needsTitle: 'You will need:',
checklist: {
photo: 'A passport-size photograph',
id: 'Your National ID (Fayda) or Kebele ID',
certificate: 'Your educational certificate',
medical: 'A medical fitness certificate and passport, if you already hold them',
},
start: 'Start registration',
},
},
seaRecords: {
title: 'My Sea Records',
pageIntro: 'Records you add here are submitted for EMA verification. Once verified they are frozen and count toward certificate eligibility.',
tabs: {
seaService: 'Sea Service',
medical: 'Medical Certificates',
},
evidence: {
title: 'Evidence',
none: 'No evidence uploaded yet.',
upload: 'Upload evidence',
uploaded: 'Evidence uploaded',
},
seaService: {
description: 'Every engagement aboard a vessel, with its evidence. Verified records feed certificate eligibility.',
approvedSeaTime: 'Approved sea time: {{days}} days',
add: 'Add sea service',
empty: 'No sea-service records yet.',
tableName: 'Sea service',
modal: {
editTitle: 'Edit sea service',
addTitle: 'Add sea service',
},
fields: {
vesselName: 'Vessel name',
imoNumber: 'IMO number',
vesselType: 'Vessel type',
flagState: 'Flag state',
grossTonnage: 'Gross tonnage',
rank: 'Rank / capacity',
engagementDate: 'Engagement date',
dischargeDate: 'Discharge date',
duties: 'Duties',
},
addRecord: 'Add record',
updated: 'Sea-service record updated',
added: 'Sea-service record added',
saveFailed: 'Could not save the record',
withdrawn: 'Record withdrawn',
deleteFailed: 'Could not delete the record',
},
medical: {
description: 'STCW medical fitness certificates. An expired certificate blocks new applications that require one.',
add: 'Add certificate',
empty: 'No medical certificates yet.',
tableName: 'Medical certificates',
modal: {
editTitle: 'Edit medical certificate',
addTitle: 'Add medical certificate',
},
fields: {
issuerName: 'Issuing clinic / physician',
certificateNumber: 'Certificate number',
issueDate: 'Issue date',
expiryDate: 'Expiry date',
fitnessOutcome: 'Fitness outcome',
restrictions: 'Restrictions',
},
updated: 'Medical certificate updated',
added: 'Medical certificate added',
saveFailed: 'Could not save the certificate',
withdrawn: 'Certificate withdrawn',
deleteFailed: 'Could not delete the certificate',
},
columns: {
vessel: 'Vessel',
imo: 'IMO {{number}}',
rank: 'Rank',
from: 'From',
to: 'To',
issuer: 'Issuer',
certNumber: '№ {{number}}',
issued: 'Issued',
expires: 'Expires',
expired: 'Expired',
fitness: 'Fitness',
fitnessOptions: {
FIT: 'Fit',
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
UNFIT: 'Unfit',
},
recordStatus: {
SUBMITTED: 'Submitted',
VERIFIED: 'Verified',
REJECTED: 'Rejected',
},
},
actions: {
evidence: 'Evidence',
scanEvidence: 'Scan / evidence',
edit: 'Edit',
delete: 'Delete',
frozen: 'Verified records are frozen',
certificatesFrozen: 'Verified certificates are frozen',
},
},
vesselRegistration: {
title: 'Vessel Registration',
registerButton: 'Register a vessel',
suspendedAlert:
'A suspended vessel may not operate. Contact the Ethiopian Maritime Authority about reinstatement.',
inFlight: {
title: 'Registrations in progress',
renewalBadge: 'Renewal',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Register an inland-waterway or sea-going vessel. Approval issues the registration certificate and enters the vessel in the national register.',
cta: 'Start registration',
},
},
notify: {
comingSoon: 'Amendment and duplicate-certificate services are coming in a later release.',
},
incident: {
modalTitle: 'Report incident — {{vesselName}}',
dateLabel: 'Date of occurrence',
locationLabel: 'Location',
descriptionLabel: 'What happened',
submit: 'Record incident',
recorded: 'Incident recorded',
recordFailed: 'Could not record the incident',
},
certificate: {
fetchFailed: 'Could not fetch the certificate',
},
renewal: {
startFailed: 'Could not start the renewal',
},
columns: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
certificate: 'Certificate',
expiresIn: 'Expires in {{count}}d',
renew: 'Renew',
renewTooltip: 'Renew the registration',
certificateTooltip: 'Download certificate',
incident: 'Incident',
incidentTooltip: 'Report accident / incident',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
status: {
notFound: 'Registration not found.',
title: 'Registration Status',
submitted: 'Submitted {{date}}',
officerRemarks: 'Officer Remarks',
pending: 'Pending',
resubmit: 'Resubmit Application',
certificatesTitle: 'Certificates',
certificateNumber: 'Certificate No. {{number}} — Issued {{date}}',
downloadedCount_one: 'Downloaded {{count}} time',
downloadedCount_other: 'Downloaded {{count}} times',
downloadedNotify: '{{certName}} downloaded.',
renewalOverdue: 'Registration is overdue for renewal.',
renewalOverdueWithExpiry: 'Registration is overdue for renewal — expires {{date}}.',
renewalDueSoon: 'Registration is due for renewal soon.',
renewalDueSoonWithExpiry: 'Registration is due for renewal soon — expires {{date}}.',
},
},
vesselTransfer: {
title: 'Ownership Transfer',
startTransfer: 'Start transfer',
startTransferDisabledTooltip: "Register a vessel first — there's nothing to transfer yet",
inFlight: {
title: 'Transfers in progress',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Ownership can only be transferred for a vessel already on the register.',
cta: 'Go to Vessel Registration',
},
},
table: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
transfer: 'Transfer',
transferTooltip: 'Start an ownership transfer for this vessel',
notTransferable: 'Not transferable',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
},
};
export type Translations = typeof en;

View File

@@ -1,6 +1,7 @@
import { AppShell } from "@mantine/core";
import { AppShell, Drawer } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconAnchor,
IconArrowsExchange,
IconBell,
IconBook2,
@@ -9,10 +10,10 @@ import {
IconHome2,
IconList,
IconRubberStamp,
IconSend,
IconShieldCheck,
IconShieldOff,
IconShip,
IconStethoscope,
IconTruck,
IconUserCircle,
} from "@tabler/icons-react";
@@ -20,7 +21,12 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { notify, AppHeader, AppSidebar, filterByPermissions } from "@ema-platform/ui";
import {
notify,
AppHeader,
AppSidebar,
filterByPermissions,
} from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
BrandMark,
@@ -68,27 +74,94 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
{
label: "nav.groupLicensing",
items: [
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck, permissions: [L.VIEW_OWN_APPLICATIONS] },
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] },
{
to: "/licensing/applications",
label: "My Applications",
i18nKey: "nav.myApplications",
icon: IconTruck,
permissions: [L.VIEW_OWN_APPLICATIONS],
},
{
to: "/waiver",
label: "Waiver",
i18nKey: "nav.waiver",
icon: IconShieldOff,
permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER],
},
],
},
{
label: "nav.groupSeafarer",
items: [
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.seamanBook', icon: IconBook2, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/licensing/BTC_BASIC_TRAINING/apply', label: 'Basic Training Certificate', i18nKey: 'nav.btc', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },
{
to: "/seafarer-registration",
label: "Seafarer Registration",
i18nKey: "nav.seafarerRegistration",
icon: IconList,
permissions: [P.APPLY_SEAFARER_REGISTRATION],
},
{
to: "/seafarer/sea-service",
label: "Sea Service",
i18nKey: "nav.seaService",
icon: IconAnchor,
permissions: [P.VIEW_OWN_SEA_SERVICE],
},
{
to: "/seafarer/medical",
label: "Medical Certificate",
i18nKey: "nav.medical",
icon: IconStethoscope,
permissions: [P.VIEW_OWN_MEDICAL],
},
{
// One page tracks both documents; the BTC needs no entry of its own.
to: "/seaman-book",
label: "SeamanBook and BTC",
i18nKey: "nav.seamanBook",
icon: IconBook2,
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
},
{
to: "/certificates",
label: "Certificates",
i18nKey: "nav.certificates",
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/exams",
label: "Examinations",
i18nKey: "nav.exams",
icon: IconList,
permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM],
},
{
to: "/endorsements",
label: "Endorsements",
i18nKey: "nav.endorsements",
icon: IconRubberStamp,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
],
},
{
label: "nav.groupVessels",
items: [
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
{
to: "/vessel-registration",
label: "Vessel Registration",
i18nKey: "nav.vesselRegistration",
icon: IconShip,
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
},
{
to: "/vessel-ownership-transfer",
label: "Ownership Transfer",
i18nKey: "nav.ownershipTransfer",
icon: IconArrowsExchange,
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
},
],
},
{
@@ -117,22 +190,26 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
];
const PAGE_META: Record<string, { i18nKey: string }> = {
'/dashboard': { i18nKey: 'nav.dashboard' },
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
'/vessel-ownership-transfer': { i18nKey: 'nav.ownershipTransfer' },
'/licensing/applications': { i18nKey: 'nav.myApplications' },
'/waiver': { i18nKey: 'nav.waiver' },
'/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' },
'/profile': { i18nKey: 'nav.profile' },
'/support': { i18nKey: 'nav.support' },
"/dashboard": { i18nKey: "nav.dashboard" },
"/vessel-registration-dashboard": {
i18nKey: "nav.vesselRegistrationDashboard",
},
"/vessel-registration": { i18nKey: "nav.vesselRegistration" },
"/vessel-ownership-transfer": { i18nKey: "nav.ownershipTransfer" },
"/licensing/applications": { i18nKey: "nav.myApplications" },
"/waiver": { i18nKey: "nav.waiver" },
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
"/seafarer/sea-service": { i18nKey: "nav.seaService" },
"/seafarer/medical": { i18nKey: "nav.medical" },
"/seaman-book": { i18nKey: "nav.seamanBook" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },
"/documents": { i18nKey: "nav.documents" },
"/notifications": { i18nKey: "nav.notifications" },
"/profile": { i18nKey: "nav.profile" },
"/support": { i18nKey: "nav.support" },
};
export function PortalLayout() {
@@ -156,7 +233,9 @@ export function PortalLayout() {
...rest,
label: t(i18nKey),
badge:
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
rest.to === "/notifications" && unseen?.count
? unseen.count
: undefined,
})),
}));
// Unfiltered until the grant list has loaded — same fail-open rule as
@@ -188,7 +267,7 @@ export function PortalLayout() {
const handleLogout = () => {
dispatch(logout());
dispatch(baseApi.util.resetApiState());
navigate("/login");
navigate("/");
};
const displayName = user?.name?.en || user?.username || "";
@@ -207,7 +286,10 @@ export function PortalLayout() {
navbar={{
width: sidebarCollapsed ? 72 : 264,
breakpoint: "sm",
collapsed: { mobile: !navOpened },
// Mobile has its own Drawer below — AppShell's built-in mobile
// navbar takes over the full viewport width, which felt like it
// swallowed the page. Always collapsed here on mobile.
collapsed: { mobile: true },
}}
padding="lg"
>
@@ -258,6 +340,29 @@ export function PortalLayout() {
<Outlet />
</div>
</AppShell.Main>
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */}
<Drawer
opened={navOpened}
onClose={closeNav}
hiddenFrom="sm"
size="75%"
padding={0}
withCloseButton={false}
>
<AppSidebar
navItems={sections}
collapsed={false}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName={t("app.name")}
brandSubtitle={t("app.authority")}
brandLogo={<BrandMark size={32} />}
/>
</Drawer>
</AppShell>
);
}

View File

@@ -1,11 +1,14 @@
import { MantineProvider } from '@mantine/core';
import { MantineProvider, mergeThemeOverrides } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { maritimeLoaderTheme } from '@ema-platform/ui';
import type { ReactNode } from 'react';
import { portalTheme } from '../theme/portalTheme';
const theme = mergeThemeOverrides(portalTheme, maritimeLoaderTheme);
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={portalTheme} defaultColorScheme="light">
<MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" />
{children}
</MantineProvider>

View File

@@ -23,19 +23,17 @@ const L = LICENSE_PERMISSIONS;
// Portal feature pages
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
// Phase 1 pages
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
import { MedicalCertificatePage } from "./features/medical/pages/MedicalCertificatePage";
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
@@ -123,14 +121,39 @@ export const router = createBrowserRouter([
{ path: "/payments/check", element: <PaymentCheckPage /> },
{ path: "/payments/success", element: <PaymentSuccessPage /> },
{ path: "/payments/failure", element: <PaymentFailurePage /> },
// Seaman Book and BTC are auto-opened together after seafarer approval.
// They use the shared status/payment page, never the generic form wizard.
{
path: "/licensing/BTC_BASIC_TRAINING/apply",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/BTC_BASIC_TRAINING/applications/:applicationId",
element: <Navigate to="/basic-training-certificate" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/apply",
element: <Navigate to="/seaman-book" replace />,
},
{
path: "/licensing/SEAMAN_BOOK/applications/:applicationId",
element: <Navigate to="/seaman-book" replace />,
},
// Seafarer registration is not a licence: it has its own page and API.
{
path: "/licensing/SEAFARER_REGISTRATION/apply",
element: <Navigate to="/seafarer-registration" replace />,
},
{
path: "/licensing/SEAFARER_REGISTRATION/applications/:applicationId",
element: <Navigate to="/seafarer-registration" replace />,
},
{
path: "/licensing/:typeCode/apply",
element: (
<RequireSeafarerProfile>
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
</RequireSeafarerProfile>
<RequirePermission anyOf={[L.CREATE_APPLICATION]}>
<LicenseApplicationPage />
</RequirePermission>
),
},
{
@@ -153,22 +176,36 @@ export const router = createBrowserRouter([
},
// Seafarer
// The standalone wizard is gone — registration is the config-driven
// licensing flow like every other licence type, gated by
// RequireSeafarerProfile + RequirePermission the same way
// /licensing/:typeCode/apply already is.
//
// Registration is its own five-step form over its own endpoints —
// not a configured licence type. A draft opens on first visit; once
// submitted the page shows the registration's status and answers.
{
path: "/seafarer-registration",
element: <Navigate to="/licensing/SEAFARER_REGISTRATION/apply" replace />,
},
{
path: "/seafarer/records",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<MySeaRecordsPage />
<RequirePermission anyOf={[P.APPLY_SEAFARER_REGISTRATION]}>
<SeafarerRegistrationPage />
</RequirePermission>
),
},
// Sea service and medical certificates each have a page of their own.
{
path: "/seafarer/sea-service",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE]}>
<SeaServicePage />
</RequirePermission>
),
},
{
path: "/seafarer/medical",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
<MedicalRecordsPage />
</RequirePermission>
),
},
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
{
path: "/exams",
element: (
@@ -201,27 +238,24 @@ export const router = createBrowserRouter([
{
path: "/seaman-book",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<RequirePermission
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
>
<SeamanBookPage />
</RequirePermission>
),
},
{
path: "/seaman-book/apply",
path: "/basic-training-certificate",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}>
<SeamanBookApplicationPage />
</RequirePermission>
),
},
{
path: "/medical",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
<MedicalCertificatePage />
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage />
</RequirePermission>
),
},
// Requested automatically with the seafarer registration — nothing to file.
{ path: "/seaman-book/apply", element: <Navigate to="/seaman-book" replace /> },
{ path: "/medical", element: <Navigate to="/seafarer/medical" replace /> },
{
path: "/basic-safety-training",
element: (
@@ -262,7 +296,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registration",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -280,7 +316,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-ownership-transfer",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselTransferPage />
</RequirePermission>
),
@@ -381,7 +419,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationPage />
</RequirePermission>
),
@@ -390,7 +430,9 @@ export const router = createBrowserRouter([
{
path: "/vessel-registrations/:id",
element: (
<RequirePermission anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}>
<RequirePermission
anyOf={[P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS]}
>
<VesselRegistrationStatusPage />
</RequirePermission>
),
@@ -427,6 +469,6 @@ export const router = createBrowserRouter([
},
// A typo'd URL should not maroon a signed-in user on the marketing page —
// ProtectedRoute sends anonymous visitors on to /login exactly as before.
// ProtectedRoute sends anonymous visitors on to / (landing) instead.
{ path: "*", element: <Navigate to="/dashboard" replace /> },
]);

View File

@@ -83,7 +83,7 @@ configureTokenRefresh({
onAuthFailure: () => {
store.dispatch(logout());
window.location.href = "/login";
window.location.href = "/";
},
});