Localization on Portal

This commit is contained in:
estifanos
2026-08-17 07:52:09 +00:00
parent 28ae9f393d
commit 73f7127ac8
39 changed files with 2270 additions and 700 deletions

View File

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

View File

@@ -1,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 BasicSafetyTrainingPage() {
const { t } = useTranslation();
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Basic Safety Training"
description="BST records are not connected to the backend yet."
title={t('featureUnavailable.basicSafetyTraining.title')}
description={t('featureUnavailable.basicSafetyTraining.description')}
/>
</Container>
);

View File

@@ -1,19 +1,23 @@
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';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function certificateColumns(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;
onDownload: (license: IssuedLicense) => void;
}): AdvancedColumn<IssuedLicense>[] {
export function certificateColumns(
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;
onDownload: (license: IssuedLicense) => void;
},
): AdvancedColumn<IssuedLicense>[] {
return [
{
header: 'Certificate №',
header: t('certificates.columns.certificateNumber', 'Certificate №'),
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.certificateNumber}
@@ -21,32 +25,32 @@ export function certificateColumns(deps: {
),
},
{
header: 'Type',
header: t('certificates.columns.type', 'Type'),
cell: ({ row }) => deps.localized(row.original.licenseType?.name),
},
{
header: 'Issued',
header: t('certificates.columns.issued', 'Issued'),
cell: ({ row }) => deps.showDate(row.original.issueDate),
},
{
header: 'Expires',
header: t('certificates.columns.expires', 'Expires'),
cell: ({ row }) => deps.showDate(row.original.expiryDate),
},
{
header: 'Status',
header: t('common.status'),
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
>
{row.original.status}
{t(`certificates.columns.licenseStatus.${row.original.status}`, row.original.status)}
</Badge>
),
},
{
header: '',
label: 'Actions',
label: t('common.actions'),
align: 'right',
cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) ? (
@@ -56,7 +60,7 @@ export function certificateColumns(deps: {
leftSection={<IconCertificate size={14} />}
onClick={() => deps.onDownload(row.original)}
>
Download
{t('common.download')}
</Button>
) : null,
},

View File

@@ -5,7 +5,6 @@ import {
Card,
Group,
List,
Loader,
Stack,
Text,
ThemeIcon,
@@ -18,6 +17,7 @@ import {
IconInfoCircle,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -70,6 +70,7 @@ function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
* issued certificates. The wizard itself is the config-driven licensing flow.
*/
export function CertificatesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: seaTime } = useGetMySeaTimeQuery();
@@ -107,46 +108,63 @@ export function CertificatesPage() {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch certificate'));
notify.error(
extractErrorMessage(error, t('certificates.fetchFailed', 'Could not fetch certificate')),
);
}
}
if (loadingProfile || loadingApplications) {
return <PageLoader label="Loading Certificates…" height={400} />;
return <PageLoader label={t('certificates.loading', 'Loading Certificates…')} height={400} />;
}
const pagedIssued = issuedTable.paginate(issued);
return (
<Stack maw={860} mx="auto">
<Title order={2}>My Certificates</Title>
<Title order={2}>{t('certificates.title', 'My Certificates')}</Title>
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} mb={6}>
Eligibility
{t('certificates.eligibility.title', 'Eligibility')}
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? `Registered seafarer (${profile?.seafarerNumber})`
: 'Active seafarer registration required'
? t('certificates.eligibility.registered', {
defaultValue: 'Registered seafarer ({{number}})',
number: profile?.seafarerNumber,
})
: t(
'certificates.eligibility.registrationRequired',
'Active seafarer registration required',
)
}
/>
<EligibilityItem
ok={hasMedical}
label={
hasMedical
? 'Current medical certificate on file'
: 'A current medical certificate is required'
? t(
'certificates.eligibility.medicalCurrent',
'Current medical certificate on file',
)
: t(
'certificates.eligibility.medicalRequired',
'A current medical certificate is required',
)
}
/>
<EligibilityItem
ok={verifiedDays > 0}
label={`Verified sea time: ${verifiedDays} days (CoC needs 360, CoP 90)`}
label={t('certificates.eligibility.seaTime', {
defaultValue: 'Verified sea time: {{days}} days (CoC needs 360, CoP 90)',
days: verifiedDays,
})}
/>
</List>
</div>
@@ -157,7 +175,7 @@ export function CertificatesPage() {
navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')
}
>
Apply for CoC
{t('certificates.applyCoc', 'Apply for CoC')}
</Button>
<Button
variant="light"
@@ -166,29 +184,32 @@ export function CertificatesPage() {
navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')
}
>
Apply for CoP
{t('certificates.applyCop', 'Apply for CoP')}
</Button>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
Complete your{' '}
{t('certificates.registrationNotice.prefix', 'Complete your')}{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
seafarer registration
{t('certificates.registrationNotice.link', 'seafarer registration')}
</Text>{' '}
first certificate applications are refused without it.
{t(
'certificates.registrationNotice.suffix',
'first — certificate applications are refused without it.',
)}
</Alert>
)}
</Card>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
<Title order={4}>{t('certificates.inProgress', 'Applications in progress')}</Title>
{inFlight.map((app) => (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between">
@@ -200,7 +221,7 @@ export function CertificatesPage() {
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
</Badge>
<Button
size="compact-sm"
@@ -212,8 +233,8 @@ export function CertificatesPage() {
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
? t('applications.actions.continue', 'Continue')
: t('applications.actions.view', 'View')}
</Button>
</Group>
</Group>
@@ -223,10 +244,10 @@ export function CertificatesPage() {
)}
<Stack gap="xs">
<Title order={4}>Issued certificates</Title>
<Title order={4}>{t('certificates.issuedCertificates', 'Issued certificates')}</Title>
<AdvancedTable
tableName="Issued certificates"
columns={certificateColumns({
tableName={t('certificates.issuedCertificates', 'Issued certificates')}
columns={certificateColumns(t, {
can,
localized,
showDate,
@@ -238,7 +259,7 @@ export function CertificatesPage() {
onPageChange={issuedTable.setPageIndex}
pageSize={issuedTable.pageSize}
refresh={refetchLicenses}
emptyText="No certificates issued yet."
emptyText={t('certificates.emptyIssued', 'No certificates issued yet.')}
/>
</Stack>
</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,
@@ -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,7 +114,7 @@ export function DashboardPage() {
}
if (isLoading) {
return <PageLoader label="Loading Dashboard…" height={450} />;
return <PageLoader label={t('dashboard.loading')} height={450} />;
}
return (
@@ -133,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>
@@ -161,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) => (
@@ -181,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)}
@@ -207,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>
@@ -228,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
@@ -246,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}
@@ -276,6 +284,7 @@ function ActionRequired({
applications: LicenseApplication[];
navigate: (path: string) => void;
}) {
const { t } = useTranslation();
return (
<Card
withBorder
@@ -288,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">
@@ -335,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;
@@ -349,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,
};
@@ -382,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 (
@@ -452,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}
@@ -482,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">
@@ -489,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

@@ -5,21 +5,19 @@ import {
Card,
Group,
List,
Loader,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconCertificate,
IconCircleCheck,
IconCircleX,
IconInfoCircle,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -31,8 +29,9 @@ import {
useGetMyLicensesQuery,
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { PageLoader, notify } from '@ema-platform/ui';
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { endorsementColumns } from './columns';
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
@@ -62,6 +61,7 @@ function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
* config-driven licensing flow.
*/
export function EndorsementPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
@@ -70,6 +70,7 @@ export function EndorsementPage() {
const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const issuedTable = useServerTable();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
@@ -83,37 +84,46 @@ export function EndorsementPage() {
const issued = (licenses?.items ?? []).filter((license) =>
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
const issuedPage = issuedTable.paginate(issued);
async function download(licenseId: string) {
try {
const result = await getCertificateUrl(licenseId).unwrap();
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not fetch endorsement'));
notify.error(
extractErrorMessage(error, t('endorsement.fetchFailed', 'Could not fetch endorsement')),
);
}
}
if (loadingProfile || loadingApplications) {
return <PageLoader label="Loading Endorsements…" height={400} />;
return <PageLoader label={t('endorsement.loading', 'Loading Endorsements…')} height={400} />;
}
return (
<Stack maw={860} mx="auto">
<Title order={2}>My Endorsements</Title>
<Title order={2}>{t('endorsement.title', 'My Endorsements')}</Title>
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} mb={6}>
Eligibility
{t('endorsement.eligibility.title', 'Eligibility')}
</Text>
<List spacing={4} size="sm">
<EligibilityItem
ok={registered}
label={
registered
? `Registered seafarer (${profile?.seafarerNumber})`
: 'Active seafarer registration required'
? t('endorsement.eligibility.registered', {
defaultValue: 'Registered seafarer ({{number}})',
number: profile?.seafarerNumber,
})
: t(
'endorsement.eligibility.registrationRequired',
'Active seafarer registration required',
)
}
/>
</List>
@@ -123,36 +133,39 @@ export function EndorsementPage() {
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
>
Endorse a CoC
{t('endorsement.endorseCoc', 'Endorse a CoC')}
</Button>
<Button
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
>
Endorse a GOC
{t('endorsement.endorseGoc', 'Endorse a GOC')}
</Button>
</Stack>
</Group>
{!registered && (
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
Complete your{' '}
{t('endorsement.registrationNotice.prefix', 'Complete your')}{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/seafarer-registration')}
>
seafarer registration
{t('endorsement.registrationNotice.link', 'seafarer registration')}
</Text>{' '}
first endorsement applications are refused without it.
{t(
'endorsement.registrationNotice.suffix',
'first — endorsement applications are refused without it.',
)}
</Alert>
)}
</Card>
{inFlight.length > 0 && (
<Stack gap="xs">
<Title order={4}>Applications in progress</Title>
<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">
@@ -164,7 +177,7 @@ export function EndorsementPage() {
</div>
<Group>
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
</Badge>
<Button
size="compact-sm"
@@ -176,8 +189,8 @@ export function EndorsementPage() {
}
>
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
? 'Continue'
: 'View'}
? t('applications.actions.continue', 'Continue')
: t('applications.actions.view', 'View')}
</Button>
</Group>
</Group>
@@ -187,62 +200,17 @@ export function EndorsementPage() {
)}
<Stack gap="xs">
<Title order={4}>Issued endorsements</Title>
{issued.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
No endorsements issued yet.
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate </Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expires</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{issued.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{license.certificateNumber}
</Text>
</Table.Td>
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={license.status === 'ACTIVE' ? 'green' : 'red'}
>
{license.status}
</Badge>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => download(license.id)}
>
Download
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<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}
emptyText={t('endorsement.emptyIssued', 'No endorsements issued yet.')}
/>
</Stack>
</Stack>
);

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,6 +11,7 @@ import {
Title,
} from '@mantine/core';
import { IconClipboardList } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
@@ -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,24 +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 <PageLoader label="Loading Exam Schedule…" height={400} />;
return <PageLoader label={t('exams.loading')} height={400} />;
}
const pagedRegistrations = registrationTable.paginate(mine ?? []);
@@ -186,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>
) : (
@@ -210,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>
@@ -220,7 +226,7 @@ export function ExamsPage() {
leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)}
>
Register
{t('exams.register')}
</Button>
</RequirePermission>
)}
@@ -231,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,
@@ -246,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,
@@ -267,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

@@ -14,6 +14,7 @@ import {
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
interface Props {
section: FormSectionConfig;
@@ -97,6 +98,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),
);
@@ -140,7 +142,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,7 @@ import {
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -59,6 +58,7 @@ 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 resetRefs = useRef<Record<string, () => void>>({});
@@ -73,7 +73,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;
}
@@ -121,17 +125,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,7 +147,7 @@ export function DocumentSlots({
)}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
Officer: {flagRemark}
{t('licensing.documents.officerRemark', { name: flagRemark })}
</Text>
)}
</div>
@@ -157,7 +161,7 @@ export function DocumentSlots({
href={existing.files[0].url}
target="_blank"
>
View
{t('licensing.documents.view')}
</Button>
)}
{!locked && (
@@ -182,7 +186,9 @@ export function DocumentSlots({
}
disabled={busy === requirement.key}
>
{uploaded ? 'Replace' : 'Upload'}
{uploaded
? t('licensing.documents.replace')
: t('licensing.documents.upload')}
</Button>
)}
</FileButton>

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

@@ -30,6 +30,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.
@@ -47,16 +48,17 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
MARITIME_PERSONNEL: IconShip,
};
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 } =
@@ -111,19 +113,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>
@@ -140,8 +140,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>
@@ -162,10 +161,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>
)}
@@ -183,8 +182,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}
@@ -193,10 +192,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>
)}
@@ -256,6 +255,7 @@ function LicenseTypeCard({
onSelect: (type: LicenseType) => void;
}) {
const localized = useLocalized();
const { t } = useTranslation();
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return (
@@ -288,23 +288,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>
)}
@@ -318,7 +318,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,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
import { useState } from 'react';
import { Button, FileButton, Group, Loader } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import {
@@ -8,6 +8,7 @@ import {
useGetAttachmentsQuery,
type StaffEvidenceRequirement,
} from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -31,6 +32,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
});
const [busy, setBusy] = useState<string | null>(null);
const localized = useLocalized();
const { t } = useTranslation();
if (!evidence?.length) return null;
@@ -49,7 +51,9 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
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;
}

View File

@@ -6,11 +6,9 @@ import {
Badge,
Button,
Card,
Center,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Paper,
@@ -72,7 +70,7 @@ import { StaffEvidence } from '../components/StaffEvidence';
export function LicenseApplicationPage() {
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
const navigate = useNavigate();
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const localized = useLocalized();
const { data: config, isLoading: loadingConfig } =
@@ -94,7 +92,7 @@ export function LicenseApplicationPage() {
.catch((err) =>
notifications.show({
color: 'red',
title: 'Could not start application',
title: t('licenseApplication.notifications.startFailed.title'),
message: extractErrorMessage(err),
}),
);
@@ -212,7 +210,7 @@ export function LicenseApplicationPage() {
);
if (loadingConfig || !config || !appId || !application) {
return <PageLoader label="Loading Application…" height={400} />;
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
}
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
@@ -254,7 +252,7 @@ export function LicenseApplicationPage() {
} catch (err) {
notifications.show({
color: 'red',
title: 'Could not save',
title: t('licenseApplication.notifications.saveFailed.title'),
message: extractErrorMessage(err),
});
}
@@ -268,8 +266,8 @@ export function LicenseApplicationPage() {
if (Object.keys(errors).length) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: 'Complete the highlighted fields before submitting.',
title: t('licenseApplication.notifications.incomplete.title'),
message: t('licenseApplication.notifications.incomplete.message'),
});
return;
}
@@ -283,15 +281,15 @@ export function LicenseApplicationPage() {
await resubmitApplication(appId as string).unwrap();
notifications.show({
color: 'teal',
title: 'Resubmitted',
message: 'Your corrections were sent back to the reviewing officer.',
title: t('licenseApplication.notifications.resubmitted.title'),
message: t('licenseApplication.notifications.resubmitted.message'),
});
} else {
await submitApplication(appId as string).unwrap();
notifications.show({
color: 'teal',
title: 'Application submitted',
message: 'You will be notified as it progresses.',
title: t('licenseApplication.notifications.submitted.title'),
message: t('licenseApplication.notifications.submitted.message'),
});
}
navigate('/licensing/applications');
@@ -300,9 +298,9 @@ export function LicenseApplicationPage() {
setIssues(found);
notifications.show({
color: 'red',
title: 'Application incomplete',
title: t('licenseApplication.notifications.applicationIncomplete.title'),
message: found.length
? `${found.length} item(s) still need attention.`
? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
: extractErrorMessage(err),
});
}
@@ -329,8 +327,8 @@ export function LicenseApplicationPage() {
if (count > 0) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
title: t('licenseApplication.notifications.incomplete.title'),
message: t('licenseApplication.notifications.incompleteFields', { count }),
});
return false;
}
@@ -344,12 +342,19 @@ 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',
title: 'Staff incomplete',
message: `Still needed: ${missing.join(', ')}.`,
title: t('licenseApplication.notifications.staffIncomplete.title'),
message: t('licenseApplication.notifications.staffIncomplete.message', {
items: missing.join(', '),
}),
});
return false;
}
@@ -369,10 +374,19 @@ 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',
message: `Upload: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` and ${missing.length - 3} more` : ''}.`,
title: t('licenseApplication.notifications.documentsMissing.title'),
message: t('licenseApplication.notifications.documentsMissing.message', {
items: shown + extra,
}),
});
return false;
}
@@ -419,7 +433,7 @@ export function LicenseApplicationPage() {
</Text>
</div>
<Text size="sm" c="dimmed">
Fee: {config.fee ?? '—'} {config.feeCurrency}
{t('licenseApplication.fee', { amount: config.fee ?? '—', currency: config.feeCurrency })}
</Text>
</Group>
@@ -427,7 +441,7 @@ export function LicenseApplicationPage() {
<Alert
color="orange"
icon={<IconAlertTriangle size={16} />}
title="Corrections requested"
title={t('licenseApplication.correctionsRequested.title')}
mb="md"
>
<Stack gap={4}>
@@ -437,14 +451,14 @@ 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>
)}
{issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t('licenseApplication.stillMissing.title')} mb="md">
<Stack gap={2}>
{issues.map((issue, i) => (
<Text size="sm" key={i}>
@@ -474,7 +488,7 @@ export function LicenseApplicationPage() {
</Text>
{locked && (
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
This section was accepted and is locked for this round.
{t('licenseApplication.sectionLocked')}
</Alert>
)}
<ConfigDrivenSection
@@ -516,18 +530,20 @@ export function LicenseApplicationPage() {
{localized(role.name)}
</Text>
<Text size="xs" c="dimmed">
{members.length} of {role.minCount} required
{t('licenseApplication.staff.requiredCount', { count: members.length, min: role.minCount })}
{role.requiredEvidence.length > 0 &&
` · each needs ${role.requiredEvidence
.filter((e) => e.mandatory)
.map((e) => localized(e.label))
.join(', ')}`}
` ${t('licenseApplication.staff.eachNeeds', {
items: role.requiredEvidence
.filter((e) => e.mandatory)
.map((e) => localized(e.label))
.join(', '),
})}`}
</Text>
</div>
<Group gap="xs">
{members.length >= role.minCount && (
<Badge color="teal" size="sm" leftSection={<IconCheck size={10} />}>
complete
{t('licenseApplication.staff.complete')}
</Badge>
)}
{!readOnly && (
@@ -537,7 +553,7 @@ export function LicenseApplicationPage() {
leftSection={<IconPlus size={14} />}
onClick={() => setStaffModal(role.roleKey)}
>
Add
{t('licenseApplication.staff.add')}
</Button>
)}
</Group>
@@ -554,7 +570,7 @@ export function LicenseApplicationPage() {
<Text size="xs" c="dimmed">
{member.position ?? '—'}
{member.yearsOfExperience
? ` · ${member.yearsOfExperience} yrs`
? ` ${t('licenseApplication.staff.yearsSuffix', { count: member.yearsOfExperience })}`
: ''}
</Text>
</div>
@@ -633,7 +649,7 @@ export function LicenseApplicationPage() {
<Divider my="lg" />
</div>
))}
<Title order={5}>Review</Title>
<Title order={5}>{t('licenseApplication.review')}</Title>
{sections.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
@@ -671,10 +687,10 @@ export function LicenseApplicationPage() {
onClick={() => setActive((s) => Math.max(0, s - 1))}
disabled={active === 0}
>
Back
{t('common.back')}
</Button>
{active < steps.length - 1 ? (
<Button onClick={handleContinue}>Continue</Button>
<Button onClick={handleContinue}>{t('common.continue')}</Button>
) : (
<RequirePermission
anyOf={
@@ -690,7 +706,9 @@ export function LicenseApplicationPage() {
disabled={readOnly}
onClick={handleSubmit}
>
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
{isAdjusting
? t('licenseApplication.resubmitCorrections')
: t('licenseApplication.submitApplication')}
</Button>
</RequirePermission>
)}
@@ -700,22 +718,22 @@ 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) => setNewStaff({ ...newStaff, fullName: e.currentTarget.value })}
/>
<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 })}
min={0}
@@ -730,7 +748,7 @@ export function LicenseApplicationPage() {
refetch();
}}
>
Add
{t('licenseApplication.staff.add')}
</Button>
</ModalFooter>
</Stack>

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 MedicalCertificatePage() {
const { t } = useTranslation();
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Medical certificate"
description="Medical certificates are not connected to the backend yet."
title={t('featureUnavailable.medical.title')}
description={t('featureUnavailable.medical.description')}
/>
</Container>
);

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,
@@ -25,12 +24,6 @@ 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.
*
@@ -38,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();
@@ -58,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
@@ -69,21 +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 ? (
<PageLoader label="Loading Notifications…" height={350} />
<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>
@@ -111,7 +110,7 @@ export function NotificationsPage() {
</Text>
{!n.isSeen && (
<Badge size="xs" variant="light">
new
{t('notifications.new')}
</Badge>
)}
</Group>
@@ -132,7 +131,7 @@ export function NotificationsPage() {
markRead(n.id);
}}
>
Mark read
{t('notifications.markRead')}
</Button>
)}
</Group>

View File

@@ -1,4 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
@@ -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,7 +77,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
}
if (isLoading) {
return <PageLoader label="Checking operations profile…" height={350} />;
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
// A failed lookup must not lock anyone out of the portal — the server still
@@ -89,7 +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 <PageLoader label="Checking operations profile…" height={350} />;
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
return <Navigate to="/onboarding/operations" replace />;
}

View File

@@ -1,5 +1,6 @@
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';
/**
@@ -14,17 +15,16 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
* 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,44 +172,47 @@ export function AddressFormContent({
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
{t('profileAddress.addressSection')}
</Text>
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
<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,
@@ -38,6 +39,7 @@ export function OperationsFormContent({
/** Where to go once the set is stored — used by the onboarding step. */
onSaved?: () => void;
} = {}) {
const { t } = useTranslation();
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
@@ -82,12 +84,12 @@ export function OperationsFormContent({
await save({ licenseTypeIds: selected }).unwrap();
setConfirmingRemoval(false);
notify.success(
'The licences you can apply for have been updated to match.',
'Operations updated',
t('profileOperations.updateSuccessBody'),
t('profileOperations.updateSuccessTitle'),
);
onSaved?.();
} catch (err) {
notify.error(extractErrorMessage(err), 'Could not save');
notify.error(extractErrorMessage(err), t('profileOperations.updateErrorTitle'));
}
}
@@ -102,10 +104,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}>
@@ -119,7 +120,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>
@@ -134,8 +135,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>
@@ -145,16 +145,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 && (
@@ -163,7 +164,7 @@ export function OperationsFormContent({
size="sm"
onClick={() => setSelected(declaredIds)}
>
Discard changes
{t('profileOperations.discardChanges')}
</Button>
)}
<Button
@@ -175,7 +176,7 @@ export function OperationsFormContent({
removed.length > 0 ? setConfirmingRemoval(true) : persist()
}
>
Save operations
{t('profileOperations.saveOperations')}
</Button>
</Group>
</Group>
@@ -185,31 +186,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

@@ -384,7 +384,7 @@ export function ProfilePage() {
trigger: addressTriggerValidation,
formState: { errors: addressErrors },
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
resolver: zodResolver(addressSchema(t)),
values: loadedAddress ?? undefined,
});

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,6 +27,7 @@ import {
IconStethoscope,
} from '@tabler/icons-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
@@ -49,7 +50,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,6 +69,7 @@ function EvidenceModal({
ownerId: string | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
@@ -85,7 +87,7 @@ function EvidenceModal({
});
setUploading(false);
if (result.ok) {
notify.success('Evidence uploaded');
notify.success(t('seaRecords.evidence.uploaded'));
refetch();
} else {
notify.error(result.error);
@@ -95,13 +97,13 @@ function EvidenceModal({
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" type="oval" />
) : files.length === 0 ? (
<Text size="sm" c="dimmed">
No evidence uploaded yet.
{t('seaRecords.evidence.none')}
</Text>
) : (
files.map((file) => (
@@ -126,7 +128,7 @@ function EvidenceModal({
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
{t('seaRecords.evidence.upload')}
</Button>
)}
</FileButton>
@@ -150,6 +152,7 @@ const EMPTY_SEA_SERVICE = {
};
function SeaServiceTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
@@ -206,23 +209,23 @@ function SeaServiceTab() {
try {
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
notify.success(t('seaRecords.seaService.updated'));
} else {
await createRecord(body).unwrap();
notify.success('Sea-service record added');
notify.success(t('seaRecords.seaService.added'));
}
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')));
}
};
@@ -237,8 +240,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,
@@ -251,25 +254,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>
) : (
@@ -277,7 +279,7 @@ function SeaServiceTab() {
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Sea service"
tableName={t('seaRecords.seaService.tableName')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -292,51 +294,51 @@ function SeaServiceTab() {
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'}
title={editing ? t('seaRecords.seaService.modal.editTitle') : t('seaRecords.seaService.modal.addTitle')}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Vessel name"
label={t('seaRecords.seaService.fields.vesselName')}
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/>
<TextInput
label="IMO number"
label={t('seaRecords.seaService.fields.imoNumber')}
value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/>
</Group>
<Group grow>
<TextInput
label="Vessel type"
label={t('seaRecords.seaService.fields.vesselType')}
value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/>
<TextInput
label="Flag state"
label={t('seaRecords.seaService.fields.flagState')}
value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/>
<NumberInput
label="Gross tonnage"
label={t('seaRecords.seaService.fields.grossTonnage')}
min={0}
value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/>
</Group>
<TextInput
label="Rank / capacity"
label={t('seaRecords.seaService.fields.rank')}
required
value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })}
/>
<Group grow>
<AmharicDatePicker
label="Engagement date"
label={t('seaRecords.seaService.fields.engagementDate')}
required
value={form.engagementDate}
onChange={(val) =>
@@ -345,7 +347,7 @@ function SeaServiceTab() {
dateFormat="date"
/>
<AmharicDatePicker
label="Discharge date"
label={t('seaRecords.seaService.fields.dischargeDate')}
required
value={form.dischargeDate}
onChange={(val) =>
@@ -355,7 +357,7 @@ function SeaServiceTab() {
/>
</Group>
<Textarea
label="Duties"
label={t('seaRecords.seaService.fields.duties')}
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
@@ -363,14 +365,14 @@ function SeaServiceTab() {
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add record'}
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
</Button>
</Group>
</Stack>
@@ -397,6 +399,7 @@ const EMPTY_MEDICAL = {
};
function MedicalTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
@@ -444,25 +447,23 @@ function MedicalTab() {
try {
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
notify.success(t('seaRecords.medical.updated'));
} else {
await createCertificate(body).unwrap();
notify.success('Medical certificate added');
notify.success(t('seaRecords.medical.added'));
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
}
};
const remove = async (certificate: MedicalCertificate) => {
try {
await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn');
notify.success(t('seaRecords.medical.withdrawn'));
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not delete the certificate'),
);
notify.error(extractErrorMessage(error, t('seaRecords.medical.deleteFailed')));
}
};
@@ -476,8 +477,8 @@ function MedicalTab() {
const page = paginate(certificates ?? []);
const columns = [
...medicalColumns(showDate),
medicalActionsColumn({
...medicalColumns(t, showDate),
medicalActionsColumn(t, {
can,
onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit,
@@ -489,19 +490,18 @@ function MedicalTab() {
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new
applications that require one.
{t('seaRecords.medical.description')}
</Text>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
{t('seaRecords.medical.add')}
</Button>
</RequirePermission>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No medical certificates yet.
{t('seaRecords.medical.empty')}
</Text>
</Paper>
) : (
@@ -509,7 +509,7 @@ function MedicalTab() {
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Medical certificates"
tableName={t('seaRecords.medical.tableName')}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -524,20 +524,20 @@ function MedicalTab() {
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
title={editing ? t('seaRecords.medical.modal.editTitle') : t('seaRecords.medical.modal.addTitle')}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Issuing clinic / physician"
label={t('seaRecords.medical.fields.issuerName')}
required
value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/>
<TextInput
label="Certificate number"
label={t('seaRecords.medical.fields.certificateNumber')}
value={form.certificateNumber}
onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value })
@@ -546,14 +546,14 @@ function MedicalTab() {
</Group>
<Group grow>
<AmharicDatePicker
label="Issue date"
label={t('seaRecords.medical.fields.issueDate')}
required
value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })}
dateFormat="date"
/>
<AmharicDatePicker
label="Expiry date"
label={t('seaRecords.medical.fields.expiryDate')}
required
value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })}
@@ -561,14 +561,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 })
@@ -577,14 +577,14 @@ function MedicalTab() {
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add certificate'}
{editing ? t('common.save') : t('seaRecords.medical.add')}
</Button>
</Group>
</Stack>
@@ -605,24 +605,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

@@ -21,9 +21,9 @@ import {
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
@@ -36,12 +36,6 @@ import {
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
const DEPARTMENT_LABELS: Record<string, string> = {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
};
const SEAFARER_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
PENDING: 'yellow',
@@ -58,6 +52,7 @@ const SEAFARER_STATUS_COLORS: Record<string, string> = {
*/
export function SeafarerRegistrationPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { profile, isLoading: loadingProfile } = useCurrentProfile();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
@@ -85,7 +80,7 @@ export function SeafarerRegistrationPage() {
const status = profile.seafarerStatus ?? 'ACTIVE';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Title order={2}>{t('seafarer.title')}</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
@@ -93,22 +88,21 @@ export function SeafarerRegistrationPage() {
<IconCircleCheck size={32} color="var(--mantine-color-green-6)" />
<div>
<Text fw={700} size="lg">
Registered Seafarer
{t('seafarer.registered.badgeTitle')}
</Text>
<Text c="dimmed" size="sm">
Your official seafarer profile with the Ethiopian Maritime
Authority.
{t('seafarer.registered.badgeSubtitle')}
</Text>
</div>
</Group>
<Badge color={SEAFARER_STATUS_COLORS[status] ?? 'gray'} size="lg">
{status}
{t(`seafarer.status.${status}`, { defaultValue: status })}
</Badge>
</Group>
<Group gap="xl" mt="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Seafarer Number
{t('seafarer.registered.seafarerNumber')}
</Text>
<Text fw={700} ff="monospace" size="lg">
{profile.seafarerNumber}
@@ -117,18 +111,21 @@ export function SeafarerRegistrationPage() {
{profile.seafarerDepartment && (
<div>
<Text size="xs" c="dimmed" tt="uppercase">
Department
{t('seafarer.registered.department')}
</Text>
<Text fw={600}>
{DEPARTMENT_LABELS[profile.seafarerDepartment] ??
profile.seafarerDepartment}
{t(`seafarer.departments.${profile.seafarerDepartment}`, {
defaultValue: profile.seafarerDepartment,
})}
</Text>
</div>
)}
</Group>
{status === 'SUSPENDED' && profile.seafarerStatusReason && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Your profile is suspended: {profile.seafarerStatusReason}
{t('seafarer.registered.suspendedAlert', {
reason: profile.seafarerStatusReason,
})}
</Alert>
)}
</Stack>
@@ -136,10 +133,9 @@ export function SeafarerRegistrationPage() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between">
<div>
<Text fw={600}>Sea service &amp; medical records</Text>
<Text fw={600}>{t('seafarer.registered.recordsTitle')}</Text>
<Text size="sm" c="dimmed">
Keep your sea-service history and medical certificates up to
date certificate and seaman-book applications draw on them.
{t('seafarer.registered.recordsSubtitle')}
</Text>
</div>
<Button
@@ -147,7 +143,7 @@ export function SeafarerRegistrationPage() {
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/seafarer/records')}
>
My records
{t('seafarer.registered.myRecords')}
</Button>
</Group>
</Paper>
@@ -161,26 +157,26 @@ export function SeafarerRegistrationPage() {
const needsAction = registration.status === 'RESUBMIT_REQUIRED';
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Title order={2}>{t('seafarer.title')}</Title>
<Card withBorder radius="md" p="xl">
<Stack>
<Group justify="space-between">
<div>
<Text fw={700}>{registration.applicationNumber}</Text>
<Text size="sm" c="dimmed">
Submitted registrations are reviewed by an EMA registration
officer; you will be notified of every decision.
{t('seafarer.inFlight.subtitle')}
</Text>
</div>
<Badge color={STATUS_COLORS[registration.status]} size="lg">
{STATUS_LABELS[registration.status]}
{t(`applications.status.${registration.status}`, {
defaultValue: registration.status,
})}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[registration.status]} />
{needsAction && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
The registration officer asked for corrections. Open the
application to see exactly what needs fixing.
{t('seafarer.inFlight.needsAction')}
</Alert>
)}
<Group>
@@ -195,10 +191,10 @@ export function SeafarerRegistrationPage() {
}
>
{isDraft
? 'Continue registration'
? t('seafarer.inFlight.continueRegistration')
: needsAction
? 'Fix and resubmit'
: 'View application'}
? t('seafarer.inFlight.fixAndResubmit')
: t('seafarer.inFlight.viewApplication')}
</Button>
</Group>
</Stack>
@@ -210,11 +206,10 @@ export function SeafarerRegistrationPage() {
// ------------------------------------------------------- not yet started
return (
<Stack maw={720} mx="auto">
<Title order={2}>Seafarer Registration</Title>
<Title order={2}>{t('seafarer.title')}</Title>
{registration?.status === 'REJECTED' && (
<Alert color="red" title="Previous registration rejected">
{registration.rejectionReason ??
'Your previous registration was rejected. You may register again.'}
<Alert color="red" title={t('seafarer.notStarted.rejectedTitle')}>
{registration.rejectionReason ?? t('seafarer.notStarted.rejectedDefault')}
</Alert>
)}
<Card withBorder radius="md" p="xl">
@@ -223,29 +218,25 @@ export function SeafarerRegistrationPage() {
<IconAnchor size={32} color="var(--mantine-color-blue-6)" />
<div>
<Text fw={700} size="lg">
Register as a seafarer
{t('seafarer.notStarted.heading')}
</Text>
<Text c="dimmed" size="sm">
Approval creates your official seafarer profile with a unique
seafarer number the identity every maritime service builds on.
{t('seafarer.notStarted.body')}
</Text>
</div>
</Group>
<Text fw={600} size="sm" mt="sm">
You will need:
{t('seafarer.notStarted.needsTitle')}
</Text>
<List
size="sm"
spacing={4}
icon={<IconClipboardList size={16} color="var(--mantine-color-blue-5)" />}
>
<List.Item>A passport-size photograph</List.Item>
<List.Item>Your National ID (Fayda) or Kebele ID</List.Item>
<List.Item>Your educational certificate</List.Item>
<List.Item>
A medical fitness certificate and passport, if you already hold
them
</List.Item>
<List.Item>{t('seafarer.notStarted.checklist.photo')}</List.Item>
<List.Item>{t('seafarer.notStarted.checklist.id')}</List.Item>
<List.Item>{t('seafarer.notStarted.checklist.certificate')}</List.Item>
<List.Item>{t('seafarer.notStarted.checklist.medical')}</List.Item>
</List>
<Group mt="md">
<RequirePermission
@@ -257,7 +248,7 @@ export function SeafarerRegistrationPage() {
rightSection={<IconArrowRight size={18} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Start registration
{t('seafarer.notStarted.start')}
</Button>
</RequirePermission>
</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 SeamanBookApplicationPage() {
const { t } = useTranslation();
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Apply for a Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
title={t('featureUnavailable.seamanBookApplication.title')}
description={t('featureUnavailable.seamanBookApplication.description')}
/>
</Container>
);

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 SeamanBookPage() {
const { t } = useTranslation();
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
title={t('featureUnavailable.seamanBook.title')}
description={t('featureUnavailable.seamanBook.description')}
/>
</Container>
);

View File

@@ -4,32 +4,36 @@ import {
IconCertificate,
IconRefresh,
} from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { IssuedLicense, Vessel } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
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',
DEREGISTERED: 'gray',
};
export function vesselColumns(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void;
onReportIncident: (vessel: Vessel) => void;
}): AdvancedColumn<Vessel>[] {
export function vesselColumns(
t: TFunction,
handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
licenseById: Map<string, IssuedLicense>;
onDownloadCertificate: (vessel: Vessel) => void;
onRenew: (vessel: Vessel) => void;
onReportIncident: (vessel: Vessel) => void;
},
): AdvancedColumn<Vessel>[] {
const categoryLabels: Record<string, string> = {
INLAND_WATERWAY: t('vesselRegistration.columns.categories.INLAND_WATERWAY'),
SEA_GOING: t('vesselRegistration.columns.categories.SEA_GOING'),
};
return [
{
header: 'Registration №',
header: t('vesselRegistration.columns.registrationNumber'),
cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}>
{row.original.registrationNumber}
@@ -37,7 +41,7 @@ export function vesselColumns(handlers: {
),
},
{
header: 'Vessel',
header: t('vesselRegistration.columns.vessel'),
cell: ({ row }) => (
<>
<Text size="sm" fw={500}>
@@ -51,12 +55,12 @@ export function vesselColumns(handlers: {
),
},
{
header: 'Category',
header: t('vesselRegistration.columns.category'),
cell: ({ row }) =>
CATEGORY_LABELS[row.original.category] ?? row.original.category,
categoryLabels[row.original.category] ?? row.original.category,
},
{
header: 'Certificate',
header: t('vesselRegistration.columns.certificate'),
cell: ({ row }) => {
const license = handlers.licenseById.get(row.original.licenseId);
const expiring =
@@ -76,7 +80,7 @@ export function vesselColumns(handlers: {
}
>
{license.status === 'ACTIVE' && expiring
? `Expires in ${license.daysUntilExpiry}d`
? t('vesselRegistration.columns.expiresIn', { count: license.daysUntilExpiry })
: license.status}
</Badge>
</Group>
@@ -88,7 +92,7 @@ export function vesselColumns(handlers: {
},
},
{
header: 'Status',
header: t('common.status'),
cell: ({ row }) => (
<Badge size="sm" color={VESSEL_STATUS_COLORS[row.original.status]}>
{row.original.status}
@@ -97,7 +101,7 @@ export function vesselColumns(handlers: {
},
{
header: '',
label: 'Actions',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const vessel = row.original;
@@ -106,33 +110,33 @@ export function vesselColumns(handlers: {
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{handlers.can([PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES]) && (
<Tooltip label="Download certificate">
<Tooltip label={t('vesselRegistration.columns.certificateTooltip')}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconCertificate size={14} />}
onClick={() => handlers.onDownloadCertificate(vessel)}
>
Certificate
{t('vesselRegistration.columns.certificate')}
</Button>
</Tooltip>
)}
{renewable &&
vessel.status === 'REGISTERED' &&
handlers.can([PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]) && (
<Tooltip label="Renew the registration">
<Tooltip label={t('vesselRegistration.columns.renewTooltip')}>
<Button
size="compact-xs"
variant="light"
leftSection={<IconRefresh size={14} />}
onClick={() => handlers.onRenew(vessel)}
>
Renew
{t('vesselRegistration.columns.renew')}
</Button>
</Tooltip>
)}
{handlers.can([PORTAL_PERMISSIONS.REPORT_VESSEL_INCIDENT]) && (
<Tooltip label="Report accident / incident">
<Tooltip label={t('vesselRegistration.columns.incidentTooltip')}>
<Button
size="compact-xs"
variant="subtle"
@@ -140,7 +144,7 @@ export function vesselColumns(handlers: {
leftSection={<IconAlertTriangle size={14} />}
onClick={() => handlers.onReportIncident(vessel)}
>
Incident
{t('vesselRegistration.columns.incident')}
</Button>
</Tooltip>
)}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
@@ -55,6 +56,7 @@ function IncidentModal({
vessel: Vessel | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const [occurredAt, setOccurredAt] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
@@ -71,13 +73,15 @@ function IncidentModal({
...(location ? { location } : {}),
},
}).unwrap();
notify.success('Incident recorded');
notify.success(t('vesselRegistration.incident.recorded'));
onClose();
setOccurredAt('');
setLocation('');
setDescription('');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not record the incident'));
notify.error(
extractErrorMessage(error, t('vesselRegistration.incident.recordFailed')),
);
}
};
@@ -85,24 +89,24 @@ function IncidentModal({
<Modal
opened={Boolean(vessel)}
onClose={onClose}
title={`Report incident — ${vessel?.name ?? ''}`}
title={t('vesselRegistration.incident.modalTitle', { vesselName: vessel?.name ?? '' })}
centered
>
<Stack>
<TextInput
type="date"
label="Date of occurrence"
label={t('vesselRegistration.incident.dateLabel')}
required
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
<TextInput
label="Location"
label={t('vesselRegistration.incident.locationLabel')}
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<Textarea
label="What happened"
label={t('vesselRegistration.incident.descriptionLabel')}
required
minRows={3}
value={description}
@@ -110,14 +114,14 @@ function IncidentModal({
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
{t('common.cancel')}
</Button>
<Button
loading={isLoading}
disabled={!occurredAt || description.trim().length < 10}
onClick={submit}
>
Record incident
{t('vesselRegistration.incident.submit')}
</Button>
</Group>
</Stack>
@@ -131,6 +135,7 @@ function IncidentModal({
* entry point into the config-driven registration wizard.
*/
export function VesselRegistrationPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels, refetch } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
@@ -159,7 +164,7 @@ export function VesselRegistrationPage() {
window.open(result.url, '_blank', 'noopener');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not fetch the certificate'),
extractErrorMessage(error, t('vesselRegistration.certificate.fetchFailed')),
);
}
}
@@ -176,7 +181,9 @@ export function VesselRegistrationPage() {
`/licensing/${REGISTRATION_TYPE_KEY}/applications/${application.id}`,
);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the renewal'));
notify.error(
extractErrorMessage(error, t('vesselRegistration.renewal.startFailed')),
);
}
}
@@ -191,7 +198,7 @@ export function VesselRegistrationPage() {
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Vessel Registration</Title>
<Title order={2}>{t('vesselRegistration.title')}</Title>
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
hideOnly
@@ -200,7 +207,7 @@ export function VesselRegistrationPage() {
leftSection={<IconPlus size={16} />}
onClick={() => navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)}
>
Register a vessel
{t('vesselRegistration.registerButton')}
</Button>
</RequirePermission>
</Group>
@@ -208,7 +215,7 @@ export function VesselRegistrationPage() {
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Registrations in progress</Title>
<Title order={4}>{t('vesselRegistration.inFlight.title')}</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
@@ -226,7 +233,7 @@ export function VesselRegistrationPage() {
</Text>
)}
{app.kind === 'RENEWAL' && (
<Badge variant="light">Renewal</Badge>
<Badge variant="light">{t('vesselRegistration.inFlight.renewalBadge')}</Badge>
)}
</Group>
<Progress
@@ -250,7 +257,11 @@ export function VesselRegistrationPage() {
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
{isDraft
? t('common.continue')
: needsAction
? t('vesselRegistration.inFlight.fix')
: t('vesselRegistration.inFlight.view')}
</Button>
</Group>
</Group>
@@ -262,16 +273,14 @@ export function VesselRegistrationPage() {
{/* ------------------------------------------------------- register */}
<Stack gap="sm">
<Title order={4}>My vessels</Title>
<Title order={4}>{t('vesselRegistration.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('vesselRegistration.myVessels.empty.title')}</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
Register an inland-waterway or sea-going vessel. Approval
issues the registration certificate and enters the vessel in
the national register.
{t('vesselRegistration.myVessels.empty.body')}
</Text>
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.APPLY_VESSEL_REGISTRATION]}
@@ -283,15 +292,15 @@ export function VesselRegistrationPage() {
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/apply`)
}
>
Start registration
{t('vesselRegistration.myVessels.empty.cta')}
</Button>
</RequirePermission>
</Stack>
</Paper>
) : (
<AdvancedTable
tableName="My vessels"
columns={vesselColumns({
tableName={t('vesselRegistration.myVessels.title')}
columns={vesselColumns(t, {
can,
licenseById,
onDownloadCertificate: downloadCertificate,
@@ -310,8 +319,7 @@ export function VesselRegistrationPage() {
{(vessels ?? []).some((v) => v.status === 'SUSPENDED') && (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
A suspended vessel may not operate. Contact the Ethiopian Maritime
Authority about reinstatement.
{t('vesselRegistration.suspendedAlert')}
</Alert>
)}
@@ -319,8 +327,7 @@ export function VesselRegistrationPage() {
<Group gap="xs">
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
<Text size="sm" c="dimmed">
Amendment and duplicate-certificate services are coming in a
later release.
{t('vesselRegistration.notify.comingSoon')}
</Text>
</Group>
</Paper>

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

@@ -112,6 +112,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: {
@@ -453,4 +503,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

@@ -111,6 +111,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: {
@@ -453,6 +503,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;