Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-20 07:17:12 +00:00
132 changed files with 9755 additions and 2847 deletions

View File

@@ -13,9 +13,115 @@
document.documentElement.setAttribute('data-mantine-color-scheme', s);
} catch (e) {}
</script>
<style>
/* Boot splash — shown until React mounts into #root. Colors are
hardcoded (not CSS vars from portal.css) so the splash never depends
on the app's own stylesheet finishing its load. */
#ema-boot-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f172a;
color: #38bdf8;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html[data-mantine-color-scheme='light'] #ema-boot-splash {
background: #f8fafc;
color: #0f2c59;
}
#ema-boot-splash .ema-card {
padding: 2.5rem 3.5rem;
border-radius: 1.5rem;
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
html[data-mantine-color-scheme='light'] #ema-boot-splash .ema-card {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(15, 44, 89, 0.1);
box-shadow: 0 25px 50px -12px rgba(11, 25, 44, 0.12);
}
#ema-boot-splash svg {
width: 140px;
height: auto;
}
.ema-boot-compass {
transform-box: fill-box;
transform-origin: center;
animation: ema-boot-spin 20s linear infinite;
}
.ema-boot-helm {
transform-box: fill-box;
transform-origin: center;
animation: ema-boot-spin-rev 14s linear infinite;
}
.ema-boot-title {
margin-top: 1rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
background: linear-gradient(135deg, #078930, #fcd116, #2563eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.ema-boot-sub {
margin-top: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
opacity: 0.85;
}
@keyframes ema-boot-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ema-boot-spin-rev {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.ema-boot-compass, .ema-boot-helm { animation: none; }
}
/* Hide splash once React mounts */
#root:not(:empty) ~ #ema-boot-splash {
display: none;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="ema-boot-splash" role="status" aria-live="polite" aria-label="Loading Ethiopian Maritime Portal">
<div class="ema-card">
<div style="position: relative; width: 120px; height: 120px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 120 120" style="position: absolute; inset: 0; width: 100%; height: 100%;">
<defs>
<linearGradient id="b-ring-1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0284C7" />
<stop offset="100%" stop-color="#078930" />
</linearGradient>
<linearGradient id="b-ring-2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F59E0B" />
<stop offset="100%" stop-color="#FCD116" />
</linearGradient>
</defs>
<circle cx="60" cy="60" r="54" fill="none" stroke="url(#b-ring-1)" stroke-width="1.8" stroke-dasharray="8 6 2 6" opacity="0.85" class="ema-boot-compass" />
<circle cx="60" cy="60" r="39" fill="none" stroke="url(#b-ring-2)" stroke-width="2" stroke-dasharray="28 14" class="ema-boot-helm" />
</svg>
<img src="/ema-logo.png" alt="EMA" style="width: 58px; height: 58px; object-fit: contain; position: relative; z-index: 2;" />
</div>
<div class="ema-boot-title">ETHIOPIAN MARITIME AUTHORITY</div>
<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 2px;">የኢትዮጵያ ማሪታይም ባለስልጣን</div>
<div class="ema-boot-sub">Loading Maritime Portal…</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel } 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';
@@ -19,16 +20,19 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
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;
}): 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;
},
): AdvancedColumn<MyRegistration>[] {
return [
{
header: 'Admission',
header: t('exams.columns.admission'),
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.admissionNumber}
@@ -36,19 +40,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"
@@ -56,25 +60,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
@@ -83,32 +87,35 @@ export function registrationColumns(deps: {
leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)}
>
Slip
{t('exams.columns.slip')}
</Button>
) : null,
},
];
}
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}
@@ -116,23 +123,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
@@ -142,7 +150,7 @@ export function resultColumns(deps: {
leftSection={<IconGavel size={13} />}
onClick={() => deps.onAppeal(row.original)}
>
Appeal
{t('exams.columns.appeal')}
</Button>
) : null;
},

View File

@@ -4,7 +4,6 @@ import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
@@ -12,7 +11,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,
@@ -79,6 +79,7 @@ export interface MyAppeal {
* when a mark looks wrong.
*/
export function ExamsPage() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -123,18 +124,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,
);
}
@@ -148,7 +152,7 @@ export function ExamsPage() {
);
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not generate the admission slip'),
extractErrorMessage(error, t('exams.notify.slipFailed')),
);
}
};
@@ -161,28 +165,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 ?? []);
@@ -190,14 +192,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>
) : (
@@ -214,7 +216,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>
@@ -224,7 +226,7 @@ export function ExamsPage() {
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
{t('exams.register')}
</Button>
</RequirePermission>
)}
@@ -235,10 +237,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,
@@ -250,15 +252,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,
@@ -271,40 +273,42 @@ export function ExamsPage() {
onPageChange={resultTable.setPageIndex}
pageSize={resultTable.pageSize}
refresh={refetchResults}
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them."
emptyText={t('exams.noResults')}
/>
</Stack>
<Modal
opened={Boolean(appealFor)}
onClose={() => setAppealFor(null)}
title="Request a review of this result"
title={t('exams.appealModal.title')}
radius="lg"
>
<Stack>
<Text size="sm" c="dimmed">
Explain what you believe went wrong with the marking or the
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
Appeals must be lodged within 14 days of publication.
{t('exams.appealModal.body', {
examTitle:
localized(appealFor?.exam?.title) ||
t('exams.appealModal.defaultExamTitle'),
})}
</Text>
<Textarea
minRows={4}
autosize
label="Grounds for appeal"
label={t('exams.appealModal.reasonLabel')}
value={appealReason}
onChange={(event) => setAppealReason(event.currentTarget.value)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setAppealFor(null)}>
Cancel
{t('common.cancel')}
</Button>
<Button
loading={appealing}
disabled={appealReason.trim().length === 0}
onClick={submitAppeal}
>
Submit appeal
{t('exams.appealModal.submit')}
</Button>
</Group>
</Stack>

View File

@@ -15,6 +15,7 @@ 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 {
@@ -99,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),
);
@@ -164,7 +166,7 @@ export function ConfigDrivenSection({
) : isVesselPicker ? (
<Select
{...common}
placeholder="Select a registered vessel"
placeholder={t('licensing.vesselPicker.placeholder')}
data={vessels.map((v) => ({ value: v.id, label: `${v.name}${v.registrationNumber}` }))}
value={(value as string) ?? null}
onChange={(v) => {

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ import {
} from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useTranslation } from 'react-i18next';
/**
* The licence catalogue an applicant chooses from, grouped by category.
@@ -52,16 +53,17 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
WAIVER_SERVICES: IconShieldOff,
};
function formatFee(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return 'No fee';
function formatFee(amount: string | number | null, currency: string, noFeeLabel: string): string {
if (amount === null || amount === '') return noFeeLabel;
const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee';
if (!Number.isFinite(value)) return noFeeLabel;
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function LicenseCatalogue() {
const navigate = useNavigate();
const localized = useLocalized();
const { t } = useTranslation();
const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery();
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
@@ -121,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>
@@ -150,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>
@@ -172,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>
)}
@@ -193,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}
@@ -203,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>
)}
@@ -266,6 +265,7 @@ function LicenseTypeCard({
onSelect: (type: LicenseType) => void;
}) {
const localized = useLocalized();
const { t } = useTranslation();
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return (
@@ -298,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>
)}
@@ -328,7 +328,9 @@ function LicenseTypeCard({
color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />}
>
{canApply ? 'Start application' : 'Add to my operations'}
{canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
</Button>
</RequirePermission>
</Box>

View File

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

View File

@@ -6,11 +6,9 @@ import {
Badge,
Button,
Card,
Center,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Paper,
@@ -76,7 +74,7 @@ import {
import { DocumentSlots } from "../components/DocumentSlots";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
function readSourcePath(
context: Record<string, unknown>,
@@ -110,7 +108,7 @@ const LEGACY_PROFILE_SOURCES: Record<string, string> = {
export function LicenseApplicationPage() {
const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams();
const navigate = useNavigate();
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const localized = useLocalized();
const accountUser = useAppSelector((state) => state.auth.user);
@@ -349,11 +347,7 @@ export function LicenseApplicationPage() {
);
if (loadingConfig || !config || !appId || !application) {
return (
<Center h={400}>
<Loader />
</Center>
);
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
}
// A submitted application stays editable until an officer takes it, which
@@ -474,7 +468,7 @@ export function LicenseApplicationPage() {
color: "red",
title: "Application incomplete",
message: found.length
? `${found.length} item(s) still need attention.`
? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
: extractErrorMessage(err),
});
}
@@ -520,7 +514,12 @@ export function LicenseApplicationPage() {
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey)
.length < role.minCount,
)
.map((role) => `${localized(role.name)} (${role.minCount} required)`);
.map((role) =>
t('licenseApplication.notifications.staffIncomplete.roleRequired', {
name: localized(role.name),
count: role.minCount,
}),
);
if (missing.length) {
notifications.show({
color: "red",
@@ -546,6 +545,13 @@ export function LicenseApplicationPage() {
.filter((req) => !supplied.has(req.key))
.map((req) => localized(req.name));
if (missing.length) {
const shown = missing.slice(0, 3).join(', ');
const extra =
missing.length > 3
? ` ${t('licenseApplication.notifications.documentsMissing.andMore', {
count: missing.length - 3,
})}`
: '';
notifications.show({
color: "red",
title: "Documents missing",
@@ -649,7 +655,7 @@ export function LicenseApplicationPage() {
<Alert
color="orange"
icon={<IconAlertTriangle size={16} />}
title="Corrections requested"
title={t('licenseApplication.correctionsRequested.title')}
mb="md"
>
<Stack gap={4}>
@@ -659,7 +665,7 @@ export function LicenseApplicationPage() {
</Text>
))}
<Text size="xs" c="dimmed" mt={4}>
Only the items listed above can be changed.
{t('licenseApplication.correctionsRequested.onlyListed')}
</Text>
</Stack>
</Alert>
@@ -980,11 +986,11 @@ export function LicenseApplicationPage() {
<Modal
opened={Boolean(staffModal)}
onClose={() => setStaffModal(null)}
title="Add staff member"
title={t('licenseApplication.staff.addStaffMember')}
>
<Stack>
<TextInput
label="Full name"
label={t('licenseApplication.staff.fullName')}
withAsterisk
value={newStaff.fullName}
onChange={(e) =>
@@ -992,14 +998,14 @@ export function LicenseApplicationPage() {
}
/>
<TextInput
label="Position"
label={t('licenseApplication.staff.position')}
value={newStaff.position}
onChange={(e) =>
setNewStaff({ ...newStaff, position: e.currentTarget.value })
}
/>
<NumberInput
label="Years of experience"
label={t('licenseApplication.staff.yearsOfExperience')}
value={newStaff.yearsOfExperience}
onChange={(v) =>
setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })
@@ -1020,7 +1026,7 @@ export function LicenseApplicationPage() {
refetch();
}}
>
Add
{t('licenseApplication.staff.add')}
</Button>
</ModalFooter>
</Stack>

View File

@@ -32,6 +32,7 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
interface MedicalCert {
id: string;
@@ -110,6 +111,8 @@ export function MedicalCertificatePage() {
resetRef.current?.();
};
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Header */}

View File

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

View File

@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom';
import { Center, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
/**
* Sends an applicant who has not said what they operate as to the step that
@@ -64,6 +65,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 +77,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 +91,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
// the same tick; deciding on the pre-save cache bounced the applicant
// straight back to the screen they had just completed.
if (isFetching) {
return (
<Center h={200}>
<Loader />
</Center>
);
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
return <Navigate to="/onboarding/operations" replace />;
}

View File

@@ -1,24 +1,30 @@
import { useNavigate } from 'react-router-dom';
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
import { useNavigate } from "react-router-dom";
import { Container, Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OperationsFormContent } from "../../profile/components/OperationsFormContent";
/**
* Where a fresh applicant lands after declaring themselves. Someone who says
* "I own a vessel" or "I am a seafarer" came here to register, so they are
* taken straight to that form instead of a dashboard that only links to it.
* "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 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: '/seafarer-registration',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
SEAFARER_REGISTRATION: "/seafarer-registration",
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
};
function nextStepFor(selectedKeys: string[]): string {
const key = Object.keys(NEXT_STEP).find((k) => selectedKeys.includes(k));
return key ? NEXT_STEP[key] : '/dashboard';
return key ? NEXT_STEP[key] : "/dashboard";
}
/**
@@ -33,17 +39,16 @@ function nextStepFor(selectedKeys: string[]): string {
* soon as the profile has at least one mode.
*/
export function OperationsOnboardingPage() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Container size="sm" py="xl">
<Stack gap="lg">
<div>
<Title order={3}>What do you operate as?</Title>
<Title order={3}>{t("onboarding.operations.title")}</Title>
<Text size="sm" c="dimmed" mt={4}>
The Authority licenses by mode of operation. Tell us what your
company does and we will show you the licences you can apply for
you can change this later from your profile.
{t("onboarding.operations.body")}
</Text>
</div>
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Center,
Container,
Group,
Loader,
@@ -17,6 +16,7 @@ import {
IconCircleCheck,
IconClockHour4,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
const POLL_INTERVAL_MS = 3000;
@@ -31,6 +31,7 @@ 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') ?? '';
@@ -68,12 +69,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
{t('payments.myApplications')}
</Button>
</Stack>
</Card>
@@ -92,18 +93,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
{t('payments.myApplications')}
</Button>
</Group>
</>
@@ -114,9 +113,9 @@ export function PaymentCheckPage() {
<IconCircleCheck size={24} />
</ThemeIcon>
)}
<Title order={4}>Confirming your payment</Title>
<Title order={4}>{t('payments.check.confirmingTitle')}</Title>
<Text size="sm" c="dimmed" ta="center">
This usually takes a few seconds. Please do not close this page.
{t('payments.check.confirmingBody')}
</Text>
</>
)}

View File

@@ -10,10 +10,12 @@ import {
Title,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } 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') ?? '';
@@ -28,18 +30,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
{t('payments.myApplications')}
</Button>
</Group>
</Stack>

View File

@@ -11,11 +11,13 @@ import {
Title,
} from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } 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();
@@ -31,10 +33,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 +43,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>
)}
@@ -68,7 +69,7 @@ export function PaymentSuccessPage() {
)}
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
Back to my applications
{t('payments.success.backToApplications')}
</Button>
</Stack>
</Card>

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,9 +49,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, splitPersonName, joinPersonName } from '@ema-platform/ui';
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';
@@ -89,6 +89,15 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase();
}
function splitProfileName(fullName: string) {
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
return { firstName, middleName, lastName: lastName.join(' ') };
}
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
return [firstName, middleName, lastName].filter(Boolean).join(' ');
}
function normalizeName(name: string) {
return name.trim().replace(/\s+/g, ' ');
}
@@ -120,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) ----
@@ -196,7 +214,7 @@ export function ProfilePage() {
// already holds so the form does not flash empty on a refetch.
const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) {
const accountName = user?.name?.en ? splitPersonName(user.name.en) : null;
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
setLoadedProfile({
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
firstName: accountName?.firstName || currentProfile.firstName || '',
@@ -215,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 || '',
@@ -245,8 +264,8 @@ export function ProfilePage() {
nameEn: z
.string()
.refine(
(name) => Object.values(splitPersonName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' },
(name) => Object.values(splitProfileName(name)).every(Boolean),
{ 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') }),
@@ -276,7 +295,7 @@ export function ProfilePage() {
setIsSavingProfile(true);
try {
const profileName = splitPersonName(values.nameEn);
const profileName = splitProfileName(values.nameEn);
const saves: Promise<unknown>[] = [
updateTrigger({
url: '/auth/update-profile',
@@ -346,16 +365,16 @@ export function ProfilePage() {
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(profileSchema(t)),
values: loadedProfile ?? undefined,
});
const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return;
const fullName = joinPersonName(values);
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;
}
@@ -372,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 {
@@ -389,7 +408,7 @@ export function ProfilePage() {
trigger: addressTriggerValidation,
formState: { errors: addressErrors },
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
resolver: zodResolver(addressSchema(t)),
values: loadedAddress ?? undefined,
});
@@ -400,7 +419,7 @@ export function ProfilePage() {
profileId,
body: toAddressPayload(values),
}).unwrap();
notify.success('Address saved');
notify.success(t('profile.addressSaved'));
} catch (e) {
handleError(e);
}
@@ -580,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')}
@@ -687,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}
@@ -714,7 +733,7 @@ export function ProfilePage() {
loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Profile
{t('profile.maritimeSection.save')}
</Button>
</Group>
</Stack>
@@ -732,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}
@@ -769,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>
@@ -845,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>
@@ -858,8 +887,11 @@ export function ProfilePage() {
</Button>
</Group>
</Stack>
</form>
</Paper>
</form>
</Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel>
{/* ---- Preferences ---- */}
@@ -888,7 +920,7 @@ export function ProfilePage() {
{t(`language.${lng}`)}
</Text>
<Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'}
{t(`profile.languageFull.${lng}`)}
</Text>
</div>
{active ? (

View File

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

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
@@ -8,18 +9,24 @@ const RECORD_STATUS_COLORS: Record<string, string> = {
REJECTED: 'red',
};
export const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
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: 'Vessel',
header: t('seaRecords.columns.vessel'),
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
@@ -27,32 +34,34 @@ export function seaServiceColumns(
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
{t('seaRecords.columns.imo', { number: row.original.imoNumber })}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{ header: t('seaRecords.columns.rank'), accessorKey: 'rank' },
{
header: 'From',
header: t('seaRecords.columns.from'),
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
header: t('seaRecords.columns.to'),
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
header: t('common.status'),
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
</Tooltip>
),
@@ -61,12 +70,14 @@ export function seaServiceColumns(
}
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: 'Issuer',
header: t('seaRecords.columns.issuer'),
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
@@ -74,41 +85,45 @@ export function medicalColumns(
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
{t('seaRecords.columns.certNumber', { number: row.original.certificateNumber })}
</Text>
)}
</>
),
},
{
header: 'Issued',
header: t('seaRecords.columns.issued'),
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
header: t('seaRecords.columns.expires'),
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
{row.original.expiryDate < today && (
<Badge color="red">{t('seaRecords.columns.expired')}</Badge>
)}
</Group>
),
},
{
header: 'Fitness',
header: t('seaRecords.columns.fitness'),
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
options.find((o) => o.value === row.original.fitnessStatus)?.label ??
row.original.fitnessStatus,
},
{
header: 'Status',
header: t('common.status'),
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
</Tooltip>
),

View File

@@ -27,7 +27,14 @@ 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,
@@ -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();
@@ -167,6 +194,7 @@ function SeaServiceTab() {
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);
@@ -211,7 +239,6 @@ function SeaServiceTab() {
let recordId = editing?.id;
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
const created = await createRecord(body).unwrap();
recordId = created.id;
@@ -236,16 +263,16 @@ function SeaServiceTab() {
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')));
}
};
@@ -260,8 +287,8 @@ function SeaServiceTab() {
const page = paginate(records ?? []);
const columns = [
...seaServiceColumns(showDate),
seaServiceActionsColumn({
...seaServiceColumns(t, showDate),
seaServiceActionsColumn(t, {
can,
onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit,
@@ -274,25 +301,24 @@ 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>
{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>
) : (
@@ -300,7 +326,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}
@@ -315,51 +341,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) =>
@@ -368,7 +394,7 @@ function SeaServiceTab() {
dateFormat="date"
/>
<AmharicDatePicker
label="Discharge date"
label={t('seaRecords.seaService.fields.dischargeDate')}
required
value={form.dischargeDate}
onChange={(val) =>
@@ -378,7 +404,7 @@ function SeaServiceTab() {
/>
</Group>
<Textarea
label="Duties"
label={t('seaRecords.seaService.fields.duties')}
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
@@ -397,14 +423,14 @@ function SeaServiceTab() {
</FileButton>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add record'}
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
</Button>
</Group>
</Stack>
@@ -431,6 +457,7 @@ const EMPTY_MEDICAL = {
};
function MedicalTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
@@ -483,7 +510,6 @@ function MedicalTab() {
let certificateId = editing?.id;
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
const created = await createCertificate(body).unwrap();
certificateId = created.id;
@@ -508,18 +534,16 @@ function MedicalTab() {
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')));
}
};
@@ -533,8 +557,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,
@@ -546,19 +570,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>
) : (
@@ -566,7 +589,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}
@@ -581,20 +604,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 })
@@ -603,14 +626,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 })}
@@ -618,14 +641,14 @@ 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 })
@@ -645,14 +668,14 @@ function MedicalTab() {
</FileButton>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating || uploadingEvidence}
>
{editing ? 'Save changes' : 'Add certificate'}
{editing ? t('common.save') : t('seaRecords.medical.add')}
</Button>
</Group>
</Stack>
@@ -673,24 +696,24 @@ function MedicalTab() {
* officer verifies them.
*/
export function MySeaRecordsPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>My Sea Records</Title>
<Title order={2}>{t('seaRecords.title')}</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.
{t('seaRecords.pageIntro')}
</Alert>
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service
{t('seaRecords.tabs.seaService')}
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical Certificates
{t('seaRecords.tabs.medical')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="md">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ export const am: Translations = {
},
common: {
select: 'ይምረጡ',
back: 'ተመለስ',
continue: 'ቀጥል',
submit: 'አስገባ',
@@ -114,6 +115,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 +208,7 @@ export const am: Translations = {
licence: 'ፍቃድ',
applicant: 'አመልካች',
progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
},
actions: {
continue: 'ቀጥል',
@@ -234,9 +286,11 @@ export const am: Translations = {
addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
seafarerBanner:
'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
},
profileSections: {
personal: 'የግል መረጃ',
@@ -264,10 +318,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 +352,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 +428,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 +558,7 @@ export const am: Translations = {
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
nameEnFullNameRequired: "እባክዎ ሙሉ ስምዎን ያስገቡ (የመጀመሪያ፣ የአባት እና የአያት ስም)",
phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
@@ -429,7 +566,7 @@ export const am: Translations = {
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
title: "መለያ ይፍጠሩ",
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
nameEnLabel: "ስም (እንግሊዝኛ)",
nameEnLabel: "ሙሉ ስም (እንግሊዝኛ)",
nameEnPlaceholder: "አበበ በቀለ",
nameAmLabel: "ስም (አማርኛ)",
nameAmPlaceholder: "ስም",
@@ -456,4 +593,637 @@ 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: 'የመጀመሪያ ሙከራ',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
ABSENT: 'አልተገኘም',
LATE: 'ዘግይቷል',
WITHDRAWN: 'ወጥቷል',
DISQUALIFIED: 'ታግዷል',
},
outcomeStatus: {
PASSED: 'አልፏል',
FAILED: 'አልተሳካም',
},
appealStatus: {
SUBMITTED: 'ገብቷል',
UNDER_REVIEW: 'በግምገማ ላይ',
UPHELD: 'ጸድቋል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
},
endorsement: {
title: "የእኔ ማረጋገጫዎች",
loading: "ማረጋገጫዎች በመጫን ላይ…",
fetchFailed: "ማረጋገጫውን ማግኘት አልተቻለም",
eligibility: {
title: "ብቁነት",
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
},
endorseCoc: "CoC ያረጋግጡ",
endorseGoc: "GOC ያረጋግጡ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
suffix: "ያጠናቅቁ — ያለዚያ የማረጋገጫ ማመልከቻዎች ውድቅ ይደረጋሉ።",
},
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
issuedEndorsements: "የወጡ ማረጋገጫዎች",
emptyIssued: "እስካሁን የወጣ ማረጋገጫ የለም።",
columns: {
certificateNumber: "የምስክር ወረቀት ቁጥር",
type: "ዓይነት",
issued: "የወጣበት ቀን",
expires: "የሚያበቃበት ቀን",
licenseStatus: {
ACTIVE: "ንቁ",
EXPIRED: "ጊዜው ያለፈበት",
SUSPENDED: "ታግዷል",
CANCELLED: "ተሰርዟል",
SUPERSEDED: "ተተክቷል",
},
},
},
seafarer: {
title: 'የባህረኛ ምዝገባ',
status: {
ACTIVE: 'ንቁ',
PENDING: 'በመጠባበቅ ላይ',
SUSPENDED: 'ታግዷል',
INACTIVE: 'ንቁ ያልሆነ',
},
departments: {
DECK: 'ዴክ',
ENGINE: 'ምህንድስና',
CATERING: 'ኬተሪንግ',
},
registered: {
badgeTitle: 'የተመዘገበ ባህረኛ',
badgeSubtitle: 'ከኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ጋር ያለዎት ይፋዊ የባህረኛ መገለጫ።',
seafarerNumber: 'የባህረኛ ቁጥር',
department: 'ክፍል',
suspendedAlert: 'መገለጫዎ ታግዷል፦ {{reason}}',
recordsTitle: 'የባህር አገልግሎትና የሕክምና መዝገቦች',
recordsSubtitle: 'የባህር አገልግሎት ታሪክዎንና የሕክምና የምስክር ወረቀቶችዎን ወቅታዊ ያድርጉ — የምስክር ወረቀትና የባህረኛ መጽሐፍ ማመልከቻዎች ከእነሱ ይመዘናሉ።',
myRecords: 'መዝገቦቼ',
},
inFlight: {
subtitle: 'የገቡ ምዝገባዎች በ EMA የምዝገባ ባለሙያ ይገመገማሉ፤ ስለ እያንዳንዱ ውሳኔ ይነገርዎታል።',
needsAction: 'የምዝገባ ባለሙያው ማስተካከያ ጠይቋል። ምን መስተካከል እንዳለበት በትክክል ለማየት ማመልከቻውን ይክፈቱ።',
continueRegistration: 'ምዝገባ ይቀጥሉ',
fixAndResubmit: 'አስተካክለው እንደገና ያስገቡ',
viewApplication: 'ማመልከቻ ይመልከቱ',
},
notStarted: {
rejectedTitle: 'ቀደም ያለ ምዝገባ ውድቅ ተደርጓል',
rejectedDefault: 'ቀደም ያለ ምዝገባዎ ውድቅ ተደርጓል። እንደገና መመዝገብ ይችላሉ።',
heading: 'እንደ ባህረኛ ይመዝገቡ',
body: 'መጽደቅ ልዩ የባህረኛ ቁጥር ያለው ይፋዊ የባህረኛ መገለጫ ይፈጥርልዎታል — እያንዳንዱ የባሕር አገልግሎት የሚገነባበት ማንነት።',
needsTitle: 'የሚያስፈልግዎት፦',
checklist: {
photo: 'የፓስፖርት መጠን ያለው ፎቶግራፍ',
id: 'ብሔራዊ መታወቂያዎ (ፋይዳ) ወይም የቀበሌ መታወቂያ',
certificate: 'የትምህርት ማስረጃዎ',
medical: 'የሕክምና ብቁነት የምስክር ወረቀትና ፓስፖርት፣ አስቀድመው ካሉዎት',
},
start: 'ምዝገባ ይጀምሩ',
},
},
seaRecords: {
title: 'የባህር መዝገቦቼ',
pageIntro: 'እዚህ የሚያክሏቸው መዝገቦች ለ EMA ማረጋገጫ ይላካሉ። ከተረጋገጡ በኋላ ይዘጋሉ እንዲሁም ለምስክር ወረቀት ብቁነት ይቆጠራሉ።',
tabs: {
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀቶች',
},
evidence: {
title: 'ማስረጃ',
none: 'እስካሁን ምንም ማስረጃ አልተሰቀለም።',
upload: 'ማስረጃ ስቀል',
uploaded: 'ማስረጃ ተሰቅሏል',
},
seaService: {
description: 'በመርከብ ላይ የተደረገ እያንዳንዱ ተሳትፎ፣ ከማስረጃው ጋር። የተረጋገጡ መዝገቦች ለምስክር ወረቀት ብቁነት ይውላሉ።',
approvedSeaTime: 'የጸደቀ የባህር ጊዜ፦ {{days}} ቀናት',
add: 'የባህር አገልግሎት ጨምር',
empty: 'እስካሁን የባህር አገልግሎት መዝገብ የለም።',
tableName: 'የባህር አገልግሎት',
modal: {
editTitle: 'የባህር አገልግሎት አርትዕ',
addTitle: 'የባህር አገልግሎት ጨምር',
},
fields: {
vesselName: 'የመርከብ ስም',
imoNumber: 'የ IMO ቁጥር',
vesselType: 'የመርከብ አይነት',
flagState: 'የባንዲራ ሀገር',
grossTonnage: 'ጠቅላላ ቶኔጅ',
rank: 'ማዕረግ / ኃላፊነት',
engagementDate: 'የተቀጠሩበት ቀን',
dischargeDate: 'የተሰናበቱበት ቀን',
duties: 'ተግባራት',
},
addRecord: 'መዝገብ ጨምር',
updated: 'የባህር አገልግሎት መዝገብ ተዘምኗል',
added: 'የባህር አገልግሎት መዝገብ ታክሏል',
saveFailed: 'መዝገቡን ማስቀመጥ አልተቻለም',
withdrawn: 'መዝገብ ተነስቷል',
deleteFailed: 'መዝገቡን መሰረዝ አልተቻለም',
},
medical: {
description: 'የ STCW የሕክምና ብቁነት የምስክር ወረቀቶች። ጊዜው ያለፈበት የምስክር ወረቀት እሱን የሚያስፈልጋቸውን አዳዲስ ማመልከቻዎች ያግዳል።',
add: 'የምስክር ወረቀት ጨምር',
empty: 'እስካሁን የሕክምና የምስክር ወረቀት የለም።',
tableName: 'የሕክምና የምስክር ወረቀቶች',
modal: {
editTitle: 'የሕክምና የምስክር ወረቀት አርትዕ',
addTitle: 'የሕክምና የምስክር ወረቀት ጨምር',
},
fields: {
issuerName: 'የሰጠው ክሊኒክ / ሐኪም',
certificateNumber: 'የምስክር ወረቀት ቁጥር',
issueDate: 'የተሰጠበት ቀን',
expiryDate: 'የሚያበቃበት ቀን',
fitnessOutcome: 'የብቁነት ውጤት',
restrictions: 'ገደቦች',
},
updated: 'የሕክምና የምስክር ወረቀት ተዘምኗል',
added: 'የሕክምና የምስክር ወረቀት ታክሏል',
saveFailed: 'የምስክር ወረቀቱን ማስቀመጥ አልተቻለም',
withdrawn: 'የምስክር ወረቀት ተነስቷል',
deleteFailed: 'የምስክር ወረቀቱን መሰረዝ አልተቻለም',
},
columns: {
vessel: 'መርከብ',
imo: 'IMO {{number}}',
rank: 'ማዕረግ',
from: 'ከ',
to: 'እስከ',
issuer: 'ሰጪ',
certNumber: '№ {{number}}',
issued: 'የተሰጠበት',
expires: 'የሚያበቃበት',
expired: 'ጊዜው አልፏል',
fitness: 'ብቁነት',
fitnessOptions: {
FIT: 'ብቁ',
FIT_WITH_RESTRICTIONS: 'በገደብ ብቁ',
UNFIT: 'ብቁ ያልሆነ',
},
recordStatus: {
SUBMITTED: 'ገብቷል',
VERIFIED: 'ተረጋግጧል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
actions: {
evidence: 'ማስረጃ',
scanEvidence: 'ስካን / ማስረጃ',
edit: 'አርትዕ',
delete: 'ሰርዝ',
frozen: 'የተረጋገጡ መዝገቦች ተዘግተዋል',
certificatesFrozen: 'የተረጋገጡ የምስክር ወረቀቶች ተዘግተዋል',
},
},
vesselRegistration: {
title: 'የመርከብ ምዝገባ',
registerButton: 'መርከብ ይመዝግቡ',
suspendedAlert:
'የታገደ መርከብ መንቀሳቀስ አይችልም። ስለ ዳግም ማቋቋም የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣንን ያግኙ።',
inFlight: {
title: 'በሂደት ላይ ያሉ ምዝገባዎች',
renewalBadge: 'እድሳት',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'የውስጥ ውሃ መስመር ወይም የባህር ማዕድ መርከብ ይመዝግቡ። ማጽደቅ የምዝገባ የምስክር ወረቀት ያወጣል እንዲሁም መርከቧን በብሔራዊ መዝገብ ውስጥ ያስገባል።',
cta: 'ምዝገባ ጀምር',
},
},
notify: {
comingSoon: 'የማሻሻያ እና የምስክር ወረቀት ድግግሞሽ አገልግሎቶች በሚቀጥለው ስሪት ይመጣሉ።',
},
incident: {
modalTitle: 'አደጋ ሪፖርት አድርግ — {{vesselName}}',
dateLabel: 'የተከሰተበት ቀን',
locationLabel: 'ቦታ',
descriptionLabel: 'የተከሰተው ነገር',
submit: 'አደጋ መዝግብ',
recorded: 'አደጋው ተመዝግቧል',
recordFailed: 'አደጋውን መመዝገብ አልተቻለም',
},
certificate: {
fetchFailed: 'የምስክር ወረቀቱን ማግኘት አልተቻለም',
},
renewal: {
startFailed: 'እድሳቱን መጀመር አልተቻለም',
},
columns: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
certificate: 'የምስክር ወረቀት',
expiresIn: 'በ{{count}} ቀን ውስጥ ያበቃል',
renew: 'አድስ',
renewTooltip: 'ምዝገባውን አድስ',
certificateTooltip: 'የምስክር ወረቀት አውርድ',
incident: 'አደጋ',
incidentTooltip: 'አደጋ / ችግር ሪፖርት አድርግ',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
status: {
notFound: 'ምዝገባ አልተገኘም።',
title: 'የምዝገባ ሁኔታ',
submitted: 'የገባው {{date}}',
officerRemarks: 'የመኮንን አስተያየት',
pending: 'በመጠባበቅ ላይ',
resubmit: 'ማመልከቻ እንደገና አስገባ',
certificatesTitle: 'የምስክር ወረቀቶች',
certificateNumber: 'የምስክር ወረቀት ቁጥር {{number}} — የወጣበት {{date}}',
downloadedCount_one: '{{count}} ጊዜ ወርዷል',
downloadedCount_other: '{{count}} ጊዜያት ወርዷል',
downloadedNotify: '{{certName}} ወርዷል።',
renewalOverdue: 'ምዝገባው እድሳት ጊዜው አልፎበታል።',
renewalOverdueWithExpiry: 'ምዝገባው እድሳት ጊዜው አልፎበታል — የሚያበቃው {{date}}።',
renewalDueSoon: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል።',
renewalDueSoonWithExpiry: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል — የሚያበቃው {{date}}።',
},
},
vesselTransfer: {
title: 'የባለቤትነት ዝውውር',
startTransfer: 'ዝውውር ጀምር',
startTransferDisabledTooltip: 'መጀመሪያ መርከብ ይመዝግቡ — እስካሁን የሚዛወር ነገር የለም',
inFlight: {
title: 'በሂደት ላይ ያሉ ዝውውሮች',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'ባለቤትነት ሊዛወር የሚችለው ቀድሞ በመዝገብ ውስጥ ላለ መርከብ ብቻ ነው።',
cta: 'ወደ የመርከብ ምዝገባ ይሂዱ',
},
},
table: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
transfer: 'አዛውር',
transferTooltip: 'ለዚህ መርከብ የባለቤትነት ዝውውር ጀምር',
notTransferable: 'ሊዛወር አይችልም',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
},
};

View File

@@ -72,6 +72,7 @@ export const en = {
},
common: {
select: 'Select',
back: 'Back',
continue: 'Continue',
submit: 'Submit',
@@ -113,6 +114,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 +207,7 @@ export const en = {
licence: 'Licence',
applicant: 'Applicant',
progress: 'Progress',
applicationNumber: 'Application №',
},
actions: {
continue: 'Continue',
@@ -236,6 +288,7 @@ export const en = {
'Seafarer registration is built from your profile — these details fill it in for you.',
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 +317,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 +351,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 +427,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 +557,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 +565,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 +592,641 @@ 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',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',
ABSENT: 'Absent',
LATE: 'Late',
WITHDRAWN: 'Withdrawn',
DISQUALIFIED: 'Disqualified',
},
outcomeStatus: {
PASSED: 'Passed',
FAILED: 'Failed',
},
appealStatus: {
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under review',
UPHELD: 'Upheld',
REJECTED: 'Rejected',
},
},
},
endorsement: {
title: 'My Endorsements',
loading: 'Loading Endorsements…',
fetchFailed: 'Could not fetch endorsement',
eligibility: {
title: 'Eligibility',
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
},
endorseCoc: 'Endorse a CoC',
endorseGoc: 'Endorse a GOC',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
suffix: 'first — endorsement applications are refused without it.',
},
inProgress: 'Applications in progress',
issuedEndorsements: 'Issued endorsements',
emptyIssued: 'No endorsements issued yet.',
columns: {
certificateNumber: 'Certificate №',
type: 'Type',
issued: 'Issued',
expires: 'Expires',
licenseStatus: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Superseded',
},
},
},
seafarer: {
title: 'Seafarer Registration',
status: {
ACTIVE: 'Active',
PENDING: 'Pending',
SUSPENDED: 'Suspended',
INACTIVE: 'Inactive',
},
departments: {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
},
registered: {
badgeTitle: 'Registered Seafarer',
badgeSubtitle: 'Your official seafarer profile with the Ethiopian Maritime Authority.',
seafarerNumber: 'Seafarer Number',
department: 'Department',
suspendedAlert: 'Your profile is suspended: {{reason}}',
recordsTitle: 'Sea service & medical records',
recordsSubtitle: 'Keep your sea-service history and medical certificates up to date — certificate and seaman-book applications draw on them.',
myRecords: 'My records',
},
inFlight: {
subtitle: 'Submitted registrations are reviewed by an EMA registration officer; you will be notified of every decision.',
needsAction: 'The registration officer asked for corrections. Open the application to see exactly what needs fixing.',
continueRegistration: 'Continue registration',
fixAndResubmit: 'Fix and resubmit',
viewApplication: 'View application',
},
notStarted: {
rejectedTitle: 'Previous registration rejected',
rejectedDefault: 'Your previous registration was rejected. You may register again.',
heading: 'Register as a seafarer',
body: 'Approval creates your official seafarer profile with a unique seafarer number — the identity every maritime service builds on.',
needsTitle: 'You will need:',
checklist: {
photo: 'A passport-size photograph',
id: 'Your National ID (Fayda) or Kebele ID',
certificate: 'Your educational certificate',
medical: 'A medical fitness certificate and passport, if you already hold them',
},
start: 'Start registration',
},
},
seaRecords: {
title: 'My Sea Records',
pageIntro: 'Records you add here are submitted for EMA verification. Once verified they are frozen and count toward certificate eligibility.',
tabs: {
seaService: 'Sea Service',
medical: 'Medical Certificates',
},
evidence: {
title: 'Evidence',
none: 'No evidence uploaded yet.',
upload: 'Upload evidence',
uploaded: 'Evidence uploaded',
},
seaService: {
description: 'Every engagement aboard a vessel, with its evidence. Verified records feed certificate eligibility.',
approvedSeaTime: 'Approved sea time: {{days}} days',
add: 'Add sea service',
empty: 'No sea-service records yet.',
tableName: 'Sea service',
modal: {
editTitle: 'Edit sea service',
addTitle: 'Add sea service',
},
fields: {
vesselName: 'Vessel name',
imoNumber: 'IMO number',
vesselType: 'Vessel type',
flagState: 'Flag state',
grossTonnage: 'Gross tonnage',
rank: 'Rank / capacity',
engagementDate: 'Engagement date',
dischargeDate: 'Discharge date',
duties: 'Duties',
},
addRecord: 'Add record',
updated: 'Sea-service record updated',
added: 'Sea-service record added',
saveFailed: 'Could not save the record',
withdrawn: 'Record withdrawn',
deleteFailed: 'Could not delete the record',
},
medical: {
description: 'STCW medical fitness certificates. An expired certificate blocks new applications that require one.',
add: 'Add certificate',
empty: 'No medical certificates yet.',
tableName: 'Medical certificates',
modal: {
editTitle: 'Edit medical certificate',
addTitle: 'Add medical certificate',
},
fields: {
issuerName: 'Issuing clinic / physician',
certificateNumber: 'Certificate number',
issueDate: 'Issue date',
expiryDate: 'Expiry date',
fitnessOutcome: 'Fitness outcome',
restrictions: 'Restrictions',
},
updated: 'Medical certificate updated',
added: 'Medical certificate added',
saveFailed: 'Could not save the certificate',
withdrawn: 'Certificate withdrawn',
deleteFailed: 'Could not delete the certificate',
},
columns: {
vessel: 'Vessel',
imo: 'IMO {{number}}',
rank: 'Rank',
from: 'From',
to: 'To',
issuer: 'Issuer',
certNumber: '№ {{number}}',
issued: 'Issued',
expires: 'Expires',
expired: 'Expired',
fitness: 'Fitness',
fitnessOptions: {
FIT: 'Fit',
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
UNFIT: 'Unfit',
},
recordStatus: {
SUBMITTED: 'Submitted',
VERIFIED: 'Verified',
REJECTED: 'Rejected',
},
},
actions: {
evidence: 'Evidence',
scanEvidence: 'Scan / evidence',
edit: 'Edit',
delete: 'Delete',
frozen: 'Verified records are frozen',
certificatesFrozen: 'Verified certificates are frozen',
},
},
vesselRegistration: {
title: 'Vessel Registration',
registerButton: 'Register a vessel',
suspendedAlert:
'A suspended vessel may not operate. Contact the Ethiopian Maritime Authority about reinstatement.',
inFlight: {
title: 'Registrations in progress',
renewalBadge: 'Renewal',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Register an inland-waterway or sea-going vessel. Approval issues the registration certificate and enters the vessel in the national register.',
cta: 'Start registration',
},
},
notify: {
comingSoon: 'Amendment and duplicate-certificate services are coming in a later release.',
},
incident: {
modalTitle: 'Report incident — {{vesselName}}',
dateLabel: 'Date of occurrence',
locationLabel: 'Location',
descriptionLabel: 'What happened',
submit: 'Record incident',
recorded: 'Incident recorded',
recordFailed: 'Could not record the incident',
},
certificate: {
fetchFailed: 'Could not fetch the certificate',
},
renewal: {
startFailed: 'Could not start the renewal',
},
columns: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
certificate: 'Certificate',
expiresIn: 'Expires in {{count}}d',
renew: 'Renew',
renewTooltip: 'Renew the registration',
certificateTooltip: 'Download certificate',
incident: 'Incident',
incidentTooltip: 'Report accident / incident',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
status: {
notFound: 'Registration not found.',
title: 'Registration Status',
submitted: 'Submitted {{date}}',
officerRemarks: 'Officer Remarks',
pending: 'Pending',
resubmit: 'Resubmit Application',
certificatesTitle: 'Certificates',
certificateNumber: 'Certificate No. {{number}} — Issued {{date}}',
downloadedCount_one: 'Downloaded {{count}} time',
downloadedCount_other: 'Downloaded {{count}} times',
downloadedNotify: '{{certName}} downloaded.',
renewalOverdue: 'Registration is overdue for renewal.',
renewalOverdueWithExpiry: 'Registration is overdue for renewal — expires {{date}}.',
renewalDueSoon: 'Registration is due for renewal soon.',
renewalDueSoonWithExpiry: 'Registration is due for renewal soon — expires {{date}}.',
},
},
vesselTransfer: {
title: 'Ownership Transfer',
startTransfer: 'Start transfer',
startTransferDisabledTooltip: "Register a vessel first — there's nothing to transfer yet",
inFlight: {
title: 'Transfers in progress',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Ownership can only be transferred for a vessel already on the register.',
cta: 'Go to Vessel Registration',
},
},
table: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
transfer: 'Transfer',
transferTooltip: 'Start an ownership transfer for this vessel',
notTransferable: 'Not transferable',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
},
};
export type Translations = typeof en;

View File

@@ -1,4 +1,4 @@
import { AppShell } from "@mantine/core";
import { AppShell, Drawer } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconArrowsExchange,
@@ -9,7 +9,6 @@ import {
IconHome2,
IconList,
IconRubberStamp,
IconSend,
IconShieldCheck,
IconShieldOff,
IconShip,
@@ -264,7 +263,7 @@ export function PortalLayout() {
const handleLogout = () => {
dispatch(logout());
dispatch(baseApi.util.resetApiState());
navigate("/login");
navigate("/");
};
const displayName = user?.name?.en || user?.username || "";
@@ -283,7 +282,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"
>
@@ -334,6 +336,29 @@ export function PortalLayout() {
<Outlet />
</div>
</AppShell.Main>
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */}
<Drawer
opened={navOpened}
onClose={closeNav}
hiddenFrom="sm"
size="75%"
padding={0}
withCloseButton={false}
>
<AppSidebar
navItems={sections}
collapsed={false}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName={t("app.name")}
brandSubtitle={t("app.authority")}
brandLogo={<BrandMark size={32} />}
/>
</Drawer>
</AppShell>
);
}

View File

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

View File

@@ -469,6 +469,6 @@ export const router = createBrowserRouter([
},
// A typo'd URL should not maroon a signed-in user on the marketing page —
// ProtectedRoute sends anonymous visitors on to /login exactly as before.
// ProtectedRoute sends anonymous visitors on to / (landing) instead.
{ path: "*", element: <Navigate to="/dashboard" replace /> },
]);

View File

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

View File

@@ -10,6 +10,15 @@ export default defineConfig({
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4200, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {