mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 11:08:13 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>[] =
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 10–15 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;
|
||||
|
||||
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal file
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>5–7 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>
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'የባህር ማዕድ',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
]);
|
||||
|
||||
@@ -83,7 +83,7 @@ configureTokenRefresh({
|
||||
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = "/login";
|
||||
window.location.href = "/";
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user