seafare registration update

This commit is contained in:
mengstabketemaw
2026-06-16 14:51:07 +03:00
parent 6f7cedc5de
commit d749c67959
29 changed files with 377 additions and 3078 deletions

View File

@@ -1,4 +1,4 @@
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
@@ -9,13 +9,37 @@ export function ColorSchemeToggle() {
const isDark = computed === 'dark';
return (
<ActionIcon
variant="subtle"
size="lg"
<UnstyledButton
aria-label={t('common.toggleTheme')}
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
{isDark ? <IconSun size={20} /> : <IconMoon size={20} />}
</ActionIcon>
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
</UnstyledButton>
);
}

View File

@@ -1,5 +1,5 @@
import { Menu, ActionIcon, Button } from '@mantine/core';
import { IconWorld, IconCheck } from '@tabler/icons-react';
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
@@ -19,21 +19,51 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
<Menu.Target>
{variant === 'icon' ? (
<ActionIcon variant="subtle" size="lg" aria-label={t('language.label')}>
<IconWorld size={20} />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<IconWorld size={16} />}
>
{t(`language.${current}`)}
</Button>
)}
<UnstyledButton
aria-label={t('language.label')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: rem(4),
width: variant === 'icon' ? rem(38) : 'auto',
height: rem(38),
padding: variant === 'icon' ? 0 : '0 12px',
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
<IconWorld size={19} />
{variant !== 'icon' && (
<>
<Text size="sm" fw={500}>
{t(`language.${current}`)}
</Text>
<IconChevronDown size={14} />
</>
)}
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Dropdown
style={{ borderRadius: rem(12), padding: rem(6) }}
>
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((lng) => (
<Menu.Item
@@ -42,6 +72,7 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
rightSection={
current === lng ? <IconCheck size={16} /> : undefined
}
style={{ borderRadius: rem(8) }}
>
{t(`language.${lng}`)}
</Menu.Item>

View File

@@ -1,33 +1,4 @@
import { Badge, Box, useMantineTheme } from '@mantine/core';
import { APPLICATION_STATUS_COLORS } from '../features/licenses/constants';
import type { ApplicationStatus } from '../features/licenses/types/license.types';
import { useLicenseLabels } from '../features/licenses/hooks/useLicenseLabels';
export function StatusPill({ status }: { status: ApplicationStatus }) {
const { applicationStatus } = useLicenseLabels();
const color = APPLICATION_STATUS_COLORS[status];
return (
<Badge
variant="light"
color={color}
radius="sm"
tt="none"
fw={600}
leftSection={
<Box
w={6}
h={6}
style={{
borderRadius: '50%',
background: `var(--mantine-color-${color}-filled)`,
}}
/>
}
>
{applicationStatus(status)}
</Badge>
);
}
import { Box, useMantineTheme } from '@mantine/core';
export function BrandAvatar({
initials = 'AB',

View File

@@ -1,96 +0,0 @@
import { useMemo } from 'react';
import { Box, useMantineTheme, useComputedColorScheme } from '@mantine/core';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { useTranslation } from 'react-i18next';
import type { Application } from '../../licenses/types/license.types';
const MONTHS_BACK = 6;
export function ApplicationsTrend({ applications }: { applications: Application[] }) {
const theme = useMantineTheme();
const { i18n } = useTranslation();
const scheme = useComputedColorScheme('light');
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
const accent = theme.colors.emaPrimary[6];
const gridColor = scheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2];
const axisColor = scheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6];
const data = useMemo(() => {
const now = new Date();
const buckets: { key: string; label: string; count: number }[] = [];
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
buckets.push({
key: `${d.getFullYear()}-${d.getMonth()}`,
label: d.toLocaleDateString(locale, { month: 'short' }),
count: 0,
});
}
const index = new Map(buckets.map((b, i) => [b.key, i]));
applications.forEach((a) => {
const d = new Date(a.submittedAt);
const k = `${d.getFullYear()}-${d.getMonth()}`;
const i = index.get(k);
if (i !== undefined) buckets[i].count += 1;
});
return buckets;
}, [applications, locale]);
return (
<Box h={240}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 8, left: -18, bottom: 0 }}>
<defs>
<linearGradient id="ema-trend" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={accent} stopOpacity={0.35} />
<stop offset="100%" stopColor={accent} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: axisColor }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12, fill: axisColor }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
cursor={{ stroke: accent, strokeWidth: 1, strokeDasharray: '4 4' }}
contentStyle={{
borderRadius: 12,
border: 'none',
boxShadow: theme.shadows.md,
background: scheme === 'dark' ? theme.colors.dark[6] : 'white',
color: scheme === 'dark' ? 'white' : 'inherit',
fontSize: 13,
}}
/>
<Area
type="monotone"
dataKey="count"
stroke={accent}
strokeWidth={2.5}
fill="url(#ema-trend)"
dot={{ r: 3, fill: accent, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</AreaChart>
</ResponsiveContainer>
</Box>
);
}

View File

@@ -1,99 +0,0 @@
import { useMemo } from 'react';
import { Box, Center, Group, Stack, Text } from '@mantine/core';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import { useMantineTheme } from '@mantine/core';
import type { Application, ApplicationStatus } from '../../licenses/types/license.types';
import { APPLICATION_STATUS_COLORS } from '../../licenses/constants';
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
import { useTranslation } from 'react-i18next';
/** Resolve a Mantine color name + shade to its hex via the theme. */
function useColorResolver() {
const theme = useMantineTheme();
return (name: string, shade = 6) => theme.colors[name]?.[shade] ?? theme.colors.gray[6];
}
export function StatusDonut({ applications }: { applications: Application[] }) {
const { t } = useTranslation();
const { applicationStatus } = useLicenseLabels();
const resolve = useColorResolver();
const data = useMemo(() => {
const counts = applications.reduce<Record<string, number>>((acc, a) => {
acc[a.status] = (acc[a.status] ?? 0) + 1;
return acc;
}, {});
return Object.entries(counts).map(([status, value]) => ({
status: status as ApplicationStatus,
value,
color: resolve(APPLICATION_STATUS_COLORS[status as ApplicationStatus]),
}));
}, [applications, resolve]);
if (!data.length) {
return (
<Center h={220}>
<Text c="dimmed" size="sm">
{t('dashboard.charts.noData')}
</Text>
</Center>
);
}
return (
<Group align="center" gap="lg" wrap="nowrap">
<Box w={180} h={180} style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="status"
cx="50%"
cy="50%"
innerRadius={58}
outerRadius={84}
paddingAngle={2}
stroke="none"
>
{data.map((d) => (
<Cell key={d.status} fill={d.color} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<Stack
gap={0}
align="center"
style={{
position: 'absolute',
inset: 0,
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Text fw={800} fz={28} lh={1}>
{applications.length}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.total')}
</Text>
</Stack>
</Box>
<Stack gap="xs" flex={1}>
{data.map((d) => (
<Group key={d.status} justify="space-between" wrap="nowrap" gap="xs">
<Group gap={8} wrap="nowrap">
<Box w={10} h={10} style={{ borderRadius: 3, background: d.color }} />
<Text size="sm">{applicationStatus(d.status)}</Text>
</Group>
<Text size="sm" fw={600}>
{d.value}
</Text>
</Group>
))}
</Stack>
</Group>
);
}

View File

@@ -1,14 +1,10 @@
import {
Box,
Button,
Card,
Center,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
@@ -16,48 +12,17 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
IconArrowRight,
IconAward,
IconChevronRight,
IconClockHour4,
IconCircleCheck,
IconFileText,
IconLifebuoy,
IconShip,
IconSquareRoundedPlus,
IconUpload,
IconUserPlus,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { useLicenses } from '../../licenses/hooks/useLicenses';
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
import { StatusDonut } from '../components/StatusDonut';
import type { ApplicationStatus } from '../../licenses/types/license.types';
import { StatusPill } from '../../../components/ui';
const OPEN_STATUSES: ApplicationStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'INFO_REQUESTED',
'PAYMENT_PENDING',
];
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
const { applications, licenses } = useLicenses();
const { licenseType, formatDate } = useLicenseLabels();
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
const approved = applications.filter(
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
).length;
const documents = applications.reduce((sum, a) => sum + a.documents.length, 0);
const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0];
const recent = applications.slice(0, 6);
return (
<Stack gap="lg">
@@ -71,24 +36,13 @@ export function DashboardPage() {
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome back, {firstName} 👋
Welcome to the EMA Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
You have {pending} applications in review and {activeLicenses} active
licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check
your status below.
Manage your seafarer profile, submit applications, and track your
maritime credentials all in one place.
</Text>
</Stack>
<Button
w="fit-content"
color="white"
c="emaPrimary.7"
radius="md"
leftSection={<IconSquareRoundedPlus size={18} />}
onClick={() => navigate('/apply')}
>
Apply for a license
</Button>
</Stack>
<Center
visibleFrom="sm"
@@ -101,179 +55,32 @@ export function DashboardPage() {
</Group>
</Paper>
{/* ---- Stat cards -------------------------------------------- */}
<SimpleGrid cols={{ base: 1, xs: 2, lg: 4 }} spacing="lg">
<StatCard
icon={IconAward}
color="teal"
value={activeLicenses}
label="Active Licenses"
trend="+1 this year"
trendColor="teal"
/>
<StatCard
icon={IconClockHour4}
color="indigo"
value={pending}
label="Pending Applications"
trend="In review"
/>
<StatCard
icon={IconCircleCheck}
color="teal"
value={approved}
label="Approved"
trend="100% pass"
trendColor="teal"
/>
<StatCard
icon={IconFileText}
color="emaPrimary"
value={documents}
label="Documents"
trend="Up to date"
/>
</SimpleGrid>
{/* ---- Lower row --------------------------------------------- */}
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper withBorder radius="lg" p="lg" h="100%">
<Group justify="space-between" mb="md">
<Title order={4}>Recent Applications</Title>
<Button
variant="subtle"
size="compact-sm"
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/applications')}
>
View all
</Button>
</Group>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Application</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={40} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recent.map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() => navigate('/applications')}
>
<Table.Td>
<Text fw={600} fz="sm">
{app.referenceNo}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">
{licenseType(app.licenseType)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">
{formatDate(app.submittedAt)}
</Text>
</Table.Td>
<Table.Td>
<StatusPill status={app.status} />
</Table.Td>
<Table.Td>
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Application Status
</Title>
<StatusDonut applications={applications} />
</Paper>
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Quick actions
</Title>
<Stack gap="xs">
<QuickAction
icon={IconSquareRoundedPlus}
color="emaPrimary"
label="Apply for new license"
onClick={() => navigate('/apply')}
/>
<QuickAction
icon={IconUpload}
color="teal"
label="Upload a document"
onClick={() => notify.info('Document upload — coming soon.')}
/>
<QuickAction
icon={IconLifebuoy}
color="orange"
label="Contact support"
onClick={() => navigate('/support')}
/>
</Stack>
</Paper>
{/* ---- Quick actions ----------------------------------------- */}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Quick actions
</Title>
<Stack gap="xs">
<QuickAction
icon={IconUserPlus}
color="emaPrimary"
label="Register a new seafarer"
onClick={() => navigate('/seafarer-registration')}
/>
<QuickAction
icon={IconLifebuoy}
color="orange"
label="Contact support"
onClick={() => navigate('/support')}
/>
</Stack>
</Grid.Col>
</Grid>
</Paper>
</SimpleGrid>
</Stack>
);
}
function StatCard({
icon: CardIcon,
color,
value,
label,
trend,
trendColor = 'gray',
}: {
icon: Icon;
color: string;
value: number;
label: string;
trend: string;
trendColor?: string;
}) {
return (
<Paper withBorder radius="lg" p="lg" className="ema-hover-lift">
<Stack gap="sm">
<Group justify="space-between" align="center">
<ThemeIcon variant="light" color={color} size={42} radius="md">
<CardIcon size={22} />
</ThemeIcon>
<Text fz="xs" fw={600} c={trendColor === 'gray' ? 'dimmed' : trendColor}>
{trend}
</Text>
</Group>
<Text fz={30} fw={700} lh={1}>
{value}
</Text>
<Text fz="sm" c="dimmed">
{label}
</Text>
</Stack>
</Paper>
);
}
function QuickAction({
icon: ActionIconCmp,
color,

View File

@@ -1,29 +0,0 @@
import { Text, Timeline } from '@mantine/core';
import { IconCheck, IconClock } from '@tabler/icons-react';
import type { TimelineEvent } from '../types/license.types';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function ApplicationTimeline({ history }: { history: TimelineEvent[] }) {
const { applicationStatus, formatDate } = useLicenseLabels();
return (
<Timeline active={history.length - 1} bulletSize={24} lineWidth={2}>
{history.map((event, idx) => (
<Timeline.Item
key={`${event.status}-${idx}`}
bullet={
idx === history.length - 1 ? <IconClock size={14} /> : <IconCheck size={14} />
}
title={applicationStatus(event.status)}
>
<Text c="dimmed" size="sm">
{event.note}
</Text>
<Text size="xs" mt={4} c="dimmed">
{formatDate(event.date)}
</Text>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -1,72 +0,0 @@
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
import { IconEye } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { Application } from '../types/license.types';
import { ApplicationStatusBadge } from './StatusBadge';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
import { EmptyState } from '../../../components/EmptyState';
export function ApplicationsTable({ applications }: { applications: Application[] }) {
const navigate = useNavigate();
const { t } = useTranslation();
const { licenseType, requestType, formatDate } = useLicenseLabels();
if (!applications.length) {
return (
<EmptyState
title={t('applications.empty')}
action={{
label: t('dashboard.newApplication'),
onClick: () => navigate('/apply'),
}}
/>
);
}
return (
<Table.ScrollContainer minWidth={760}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t('applications.columns.reference')}</Table.Th>
<Table.Th>{t('applications.columns.licenseType')}</Table.Th>
<Table.Th>{t('applications.columns.request')}</Table.Th>
<Table.Th>{t('applications.columns.subject')}</Table.Th>
<Table.Th>{t('applications.columns.submitted')}</Table.Th>
<Table.Th>{t('applications.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{applications.map((app) => (
<Table.Tr key={app.id}>
<Table.Td>
<Anchor fw={600} onClick={() => navigate(`/applications/${app.id}`)}>
{app.referenceNo}
</Anchor>
</Table.Td>
<Table.Td>{licenseType(app.licenseType)}</Table.Td>
<Table.Td>{requestType(app.requestType)}</Table.Td>
<Table.Td>{app.subjectName}</Table.Td>
<Table.Td>{formatDate(app.submittedAt)}</Table.Td>
<Table.Td>
<ApplicationStatusBadge status={app.status} />
</Table.Td>
<Table.Td>
<Tooltip label={t('common.viewDetails')}>
<ActionIcon
variant="subtle"
onClick={() => navigate(`/applications/${app.id}`)}
>
<IconEye size={18} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,43 +0,0 @@
import { Badge, Group, Stack, Text, ThemeIcon } from '@mantine/core';
import {
IconFileCheck,
IconFileUpload,
IconFileX,
IconFileDots,
} from '@tabler/icons-react';
import type { ApplicationDocument, DocumentStatus } from '../types/license.types';
import { DOCUMENT_STATUS_COLORS } from '../constants';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
const STATUS_ICON: Record<DocumentStatus, typeof IconFileCheck> = {
VERIFIED: IconFileCheck,
UPLOADED: IconFileUpload,
REJECTED: IconFileX,
PENDING: IconFileDots,
};
export function DocumentsList({ documents }: { documents: ApplicationDocument[] }) {
const { doc, documentStatus } = useLicenseLabels();
return (
<Stack gap="xs">
{documents.map((d) => {
const Icon = STATUS_ICON[d.status];
const color = DOCUMENT_STATUS_COLORS[d.status];
return (
<Group key={d.name} justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={34} radius="md">
<Icon size={18} />
</ThemeIcon>
<Text size="sm">{doc(d.name)}</Text>
</Group>
<Badge variant="light" color={color} radius="sm">
{documentStatus(d.status)}
</Badge>
</Group>
);
})}
</Stack>
);
}

View File

@@ -1,59 +0,0 @@
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
import { IconEye } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { License } from '../types/license.types';
import { LicenseStatusBadge } from './StatusBadge';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function LicensesTable({ licenses }: { licenses: License[] }) {
const navigate = useNavigate();
const { t } = useTranslation();
const { licenseType, formatDate } = useLicenseLabels();
return (
<Table.ScrollContainer minWidth={760}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t('licenses.columns.licenseNo')}</Table.Th>
<Table.Th>{t('licenses.columns.type')}</Table.Th>
<Table.Th>{t('licenses.columns.subject')}</Table.Th>
<Table.Th>{t('licenses.columns.issued')}</Table.Th>
<Table.Th>{t('licenses.columns.expires')}</Table.Th>
<Table.Th>{t('licenses.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{licenses.map((lic) => (
<Table.Tr key={lic.id}>
<Table.Td>
<Anchor fw={600} onClick={() => navigate(`/licenses/${lic.id}`)}>
{lic.licenseNo}
</Anchor>
</Table.Td>
<Table.Td>{licenseType(lic.licenseType)}</Table.Td>
<Table.Td>{lic.subjectName}</Table.Td>
<Table.Td>{formatDate(lic.issuedAt)}</Table.Td>
<Table.Td>{formatDate(lic.expiresAt)}</Table.Td>
<Table.Td>
<LicenseStatusBadge status={lic.status} />
</Table.Td>
<Table.Td>
<Tooltip label={t('common.viewDetails')}>
<ActionIcon
variant="subtle"
onClick={() => navigate(`/licenses/${lic.id}`)}
>
<IconEye size={18} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,79 +0,0 @@
import { Badge, Button, Card, Group, List, Stack, Text, ThemeIcon } from '@mantine/core';
import {
IconClock,
IconCalendarStats,
IconArrowRight,
IconFileText,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { LicenseType } from '../types/license.types';
import {
LICENSE_PROCESSING_DAYS,
LICENSE_VALIDITY_YEARS,
REQUIRED_DOCUMENTS,
} from '../constants';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function ServiceCard({ type }: { type: LicenseType }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { licenseType, doc } = useLicenseLabels();
const [min, max] = LICENSE_PROCESSING_DAYS[type];
const years = LICENSE_VALIDITY_YEARS[type];
const docs = REQUIRED_DOCUMENTS[type];
return (
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
<Stack gap="sm" h="100%">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={650} lh={1.25}>
{licenseType(type)}
</Text>
<ThemeIcon variant="light" radius="md" size={38} color="emaPrimary">
<IconFileText size={20} />
</ThemeIcon>
</Group>
<Group gap="xs">
<Badge
variant="light"
color="indigo"
leftSection={<IconClock size={12} />}
>
{t('services.processingDays', { min, max })}
</Badge>
<Badge
variant="light"
color="teal"
leftSection={<IconCalendarStats size={12} />}
>
{t('services.validityYears', { count: years })}
</Badge>
</Group>
<div>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={4}>
{t('services.requiredDocuments')}
</Text>
<List size="sm" spacing={2} c="dimmed">
{docs.slice(0, 3).map((d) => (
<List.Item key={d}>{doc(d)}</List.Item>
))}
{docs.length > 3 && <List.Item>+{docs.length - 3}</List.Item>}
</List>
</div>
<Button
mt="auto"
variant="light"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate(`/apply?type=${type}`)}
>
{t('services.apply')}
</Button>
</Stack>
</Card>
);
}

View File

@@ -1,62 +0,0 @@
import { Group, Paper, Text, ThemeIcon } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
interface StatCardProps {
label: string;
value: number | string;
icon: Icon;
color?: string;
/** Optional helper line under the value. */
hint?: string;
}
export function StatCard({
label,
value,
icon: IconCmp,
color = 'emaPrimary',
hint,
}: StatCardProps) {
return (
<Paper
p="lg"
shadow="sm"
withBorder
className="ema-hover-lift"
style={{ overflow: 'hidden', position: 'relative' }}
>
{/* faint accent wash in the corner */}
<div
aria-hidden
style={{
position: 'absolute',
top: -28,
right: -28,
width: 96,
height: 96,
borderRadius: '50%',
background: `var(--mantine-color-${color}-light)`,
opacity: 0.5,
}}
/>
<Group justify="space-between" align="flex-start" wrap="nowrap" style={{ position: 'relative' }}>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lts={0.3}>
{label}
</Text>
<Text fw={800} fz={30} lh={1.1} mt={6}>
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={4}>
{hint}
</Text>
)}
</div>
<ThemeIcon color={color} variant="light" size={46} radius="md">
<IconCmp size={24} />
</ThemeIcon>
</Group>
</Paper>
);
}

View File

@@ -1,22 +0,0 @@
import { Badge } from '@mantine/core';
import { APPLICATION_STATUS_COLORS, LICENSE_STATUS_COLORS } from '../constants';
import type { ApplicationStatus, LicenseStatus } from '../types/license.types';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function ApplicationStatusBadge({ status }: { status: ApplicationStatus }) {
const { applicationStatus } = useLicenseLabels();
return (
<Badge color={APPLICATION_STATUS_COLORS[status]} variant="light" radius="sm">
{applicationStatus(status)}
</Badge>
);
}
export function LicenseStatusBadge({ status }: { status: LicenseStatus }) {
const { licenseStatus } = useLicenseLabels();
return (
<Badge color={LICENSE_STATUS_COLORS[status]} variant="light" radius="sm">
{licenseStatus(status)}
</Badge>
);
}

View File

@@ -1,145 +0,0 @@
import type {
ApplicationStatus,
DocumentStatus,
LicenseStatus,
LicenseType,
RequestType,
} from './types/license.types';
export const LICENSE_TYPE_LABELS: Record<LicenseType, string> = {
SEAFARER_COC: 'Certificate of Competency (CoC)',
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
SHIP_RADIO: 'Ship Station Radio Licence',
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
SAFETY_MANAGEMENT: 'Safety Management Certificate',
PORT_FACILITY: 'Port Facility Operation Licence',
BOAT_OPERATOR: 'Inland Boat Operator Licence',
};
/** Whether a licence type is about a person (seafarer) or an asset (vessel/facility). */
export const LICENSE_SUBJECT_LABEL: Record<LicenseType, string> = {
SEAFARER_COC: 'Seafarer full name',
SEAFARER_COP: 'Seafarer full name',
SEAMAN_BOOK: 'Seafarer full name',
VESSEL_REGISTRATION: 'Vessel name',
SHIP_RADIO: 'Vessel name',
TONNAGE_CERTIFICATE: 'Vessel name',
SAFETY_MANAGEMENT: 'Vessel name',
PORT_FACILITY: 'Facility name',
BOAT_OPERATOR: 'Operator full name',
};
/** Documents typically required, keyed by licence type. */
export const REQUIRED_DOCUMENTS: Record<LicenseType, string[]> = {
SEAFARER_COC: ['Passport copy', 'Training certificate', 'Medical fitness certificate', 'Sea service record'],
SEAFARER_COP: ['Passport copy', 'Course completion certificate', 'Medical fitness certificate'],
SEAMAN_BOOK: ['Passport copy', 'Passport photo', 'Police clearance'],
VESSEL_REGISTRATION: ['Bill of sale', 'Builder certificate', 'Tonnage measurement', 'Insurance certificate'],
SHIP_RADIO: ['Vessel registration', 'Equipment specification', 'Operator certificate'],
TONNAGE_CERTIFICATE: ['Vessel registration', 'Survey report', 'General arrangement plan'],
SAFETY_MANAGEMENT: ['Vessel registration', 'Safety management manual', 'Audit report'],
PORT_FACILITY: ['Business licence', 'Facility security assessment', 'Site plan'],
BOAT_OPERATOR: ['ID copy', 'Training certificate', 'Medical fitness certificate'],
};
export const REQUEST_TYPE_LABELS: Record<RequestType, string> = {
NEW: 'New Application',
RENEWAL: 'Renewal',
AMENDMENT: 'Amendment',
COMPLIANCE: 'Compliance Submission',
};
export const APPLICATION_STATUS_LABELS: Record<ApplicationStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
INFO_REQUESTED: 'Information Requested',
PAYMENT_PENDING: 'Payment Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ISSUED: 'Issued',
};
export const APPLICATION_STATUS_COLORS: Record<ApplicationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
INFO_REQUESTED: 'orange',
PAYMENT_PENDING: 'yellow',
APPROVED: 'teal',
REJECTED: 'red',
ISSUED: 'green',
};
export const LICENSE_STATUS_LABELS: Record<LicenseStatus, string> = {
ACTIVE: 'Active',
EXPIRING_SOON: 'Expiring Soon',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
};
export const LICENSE_STATUS_COLORS: Record<LicenseStatus, string> = {
ACTIVE: 'green',
EXPIRING_SOON: 'orange',
EXPIRED: 'red',
SUSPENDED: 'grape',
};
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
PENDING: 'gray',
UPLOADED: 'blue',
VERIFIED: 'teal',
REJECTED: 'red',
};
/** Service-catalog grouping for each licence type. */
export type ServiceCategory = 'seafarer' | 'vessel' | 'facility';
export const LICENSE_CATEGORY: Record<LicenseType, ServiceCategory> = {
SEAFARER_COC: 'seafarer',
SEAFARER_COP: 'seafarer',
SEAMAN_BOOK: 'seafarer',
BOAT_OPERATOR: 'seafarer',
VESSEL_REGISTRATION: 'vessel',
SHIP_RADIO: 'vessel',
TONNAGE_CERTIFICATE: 'vessel',
SAFETY_MANAGEMENT: 'vessel',
PORT_FACILITY: 'facility',
};
/** Indicative processing window [min, max] in working days (template data). */
export const LICENSE_PROCESSING_DAYS: Record<LicenseType, [number, number]> = {
SEAFARER_COC: [10, 15],
SEAFARER_COP: [5, 10],
SEAMAN_BOOK: [3, 7],
VESSEL_REGISTRATION: [10, 20],
SHIP_RADIO: [5, 10],
TONNAGE_CERTIFICATE: [7, 14],
SAFETY_MANAGEMENT: [10, 15],
PORT_FACILITY: [15, 30],
BOAT_OPERATOR: [3, 7],
};
/** Validity period in years (template data). */
export const LICENSE_VALIDITY_YEARS: Record<LicenseType, number> = {
SEAFARER_COC: 5,
SEAFARER_COP: 5,
SEAMAN_BOOK: 10,
VESSEL_REGISTRATION: 5,
SHIP_RADIO: 1,
TONNAGE_CERTIFICATE: 5,
SAFETY_MANAGEMENT: 5,
PORT_FACILITY: 3,
BOAT_OPERATOR: 3,
};
/** Ordered pipeline used to render application progress. */
export const APPLICATION_PIPELINE: ApplicationStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'PAYMENT_PENDING',
'APPROVED',
'ISSUED',
];

View File

@@ -1,35 +0,0 @@
import { useTranslation } from 'react-i18next';
import type {
ApplicationStatus,
DocumentStatus,
LicenseStatus,
LicenseType,
RequestType,
} from '../types/license.types';
/**
* Resolves domain enum values to localized labels and formats dates in the
* active language. Keeps component markup free of `t(\`enum.${v}\`)` noise.
*/
export function useLicenseLabels() {
const { t, i18n } = useTranslation();
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return {
licenseType: (v: LicenseType) => t(`licenseType.${v}`),
subjectLabel: (v: LicenseType) => t(`subjectLabel.${v}`),
requestType: (v: RequestType) => t(`requestType.${v}`),
applicationStatus: (v: ApplicationStatus) => t(`applicationStatus.${v}`),
licenseStatus: (v: LicenseStatus) => t(`licenseStatus.${v}`),
documentStatus: (v: DocumentStatus) => t(`documentStatus.${v}`),
doc: (name: string) => t(`documents.${name}`, { defaultValue: name }),
formatDate,
};
}

View File

@@ -1,25 +0,0 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { submitApplication } from '../store/licenses.slice';
import type { NewApplicationInput } from '../types/license.types';
export function useLicenses() {
const dispatch = useAppDispatch();
const applications = useAppSelector((s) => s.licenses.applications);
const licenses = useAppSelector((s) => s.licenses.licenses);
return {
applications,
licenses,
submit: (input: NewApplicationInput) => dispatch(submitApplication(input)),
};
}
export function useApplication(id: string | undefined) {
return useAppSelector((s) =>
s.licenses.applications.find((a) => a.id === id),
);
}
export function useLicense(id: string | undefined) {
return useAppSelector((s) => s.licenses.licenses.find((l) => l.id === id));
}

View File

@@ -1,143 +0,0 @@
import {
Alert,
Button,
Grid,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
import { EmptyState } from '../../../components/EmptyState';
import { ApplicationStatusBadge } from '../components/StatusBadge';
import { ApplicationTimeline } from '../components/ApplicationTimeline';
import { DocumentsList } from '../components/DocumentsList';
import { useApplication } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function ApplicationDetailPage() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const app = useApplication(id);
const { licenseType, requestType, formatDate } = useLicenseLabels();
if (!app) {
return (
<EmptyState
title={t('common.notFound')}
action={{
label: t('common.backToList'),
onClick: () => navigate('/applications'),
}}
/>
);
}
return (
<Stack gap="lg">
<Button
variant="subtle"
size="sm"
leftSection={<IconArrowLeft size={16} />}
onClick={() => navigate('/applications')}
w="fit-content"
>
{t('common.backToList')}
</Button>
<PageHeader
title={`${t('applicationDetail.title')} · ${app.referenceNo}`}
subtitle={licenseType(app.licenseType)}
action={<ApplicationStatusBadge status={app.status} />}
/>
{app.status === 'INFO_REQUESTED' && (
<Alert
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
title={t('applicationDetail.actionRequired')}
>
<Stack gap="xs">
<Text size="sm">{app.notes ?? t('applicationDetail.actionRequiredDesc')}</Text>
<Button color="orange" size="xs" w="fit-content">
{t('applicationDetail.respond')}
</Button>
</Stack>
</Alert>
)}
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="md">
<Paper p="md" shadow="sm" radius="md" withBorder>
<Title order={5} mb="md">
{t('applicationDetail.overview')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Field label={t('applicationDetail.applicant')} value={app.applicantName} />
<Field label={t('applicationDetail.subject')} value={app.subjectName} />
<Field
label={t('applicationDetail.requestType')}
value={requestType(app.requestType)}
/>
<Field
label={t('applicationDetail.licenseType')}
value={licenseType(app.licenseType)}
/>
<Field
label={t('applicationDetail.submittedOn')}
value={formatDate(app.submittedAt)}
/>
<Field
label={t('applicationDetail.lastUpdated')}
value={formatDate(app.updatedAt)}
/>
{app.relatedLicenseNo && (
<Field
label={t('applicationDetail.relatedLicense')}
value={app.relatedLicenseNo}
/>
)}
</SimpleGrid>
</Paper>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Title order={5} mb="md">
{t('applicationDetail.documents')}
</Title>
<DocumentsList documents={app.documents} />
</Paper>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 5 }}>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Title order={5} mb="md">
{t('applicationDetail.progress')}
</Title>
<ApplicationTimeline history={app.history} />
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm" mt={2}>
{value}
</Text>
</div>
);
}

View File

@@ -1,349 +0,0 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Divider,
Grid,
Group,
Paper,
Stack,
Table,
Text,
ThemeIcon,
Timeline,
Title,
UnstyledButton,
} from '@mantine/core';
import {
IconAdjustmentsHorizontal,
IconCheck,
IconChevronRight,
IconClock,
IconCircleX,
IconDownload,
IconMessageCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useLicenses } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
import { APPLICATION_PIPELINE } from '../constants';
import type {
Application,
ApplicationStatus,
LicenseType,
} from '../types/license.types';
import { StatusPill } from '../../../components/ui';
const SERVICE_FEE: Record<LicenseType, number> = {
SEAFARER_COC: 1200,
SEAFARER_COP: 900,
SEAMAN_BOOK: 600,
VESSEL_REGISTRATION: 3500,
SHIP_RADIO: 900,
TONNAGE_CERTIFICATE: 2400,
SAFETY_MANAGEMENT: 5000,
PORT_FACILITY: 5000,
BOAT_OPERATOR: 700,
};
const etb = (n: number) => `ETB ${n.toLocaleString()}`;
const PIPELINE_LABELS: Record<string, string> = {
SUBMITTED: 'Application submitted',
UNDER_REVIEW: 'Document verification & review',
PAYMENT_PENDING: 'Payment & assessment',
APPROVED: 'Approval decision',
ISSUED: 'License issued',
};
function stageIndex(status: ApplicationStatus): number {
if (status === 'DRAFT') return 0;
if (status === 'INFO_REQUESTED') return 1;
if (status === 'REJECTED') return 3;
const i = APPLICATION_PIPELINE.indexOf(status);
return i < 0 ? 0 : i;
}
type TabKey = 'all' | 'review' | 'approved' | 'pending';
export function ApplicationsListPage() {
const { applications } = useLicenses();
const { licenseType, formatDate } = useLicenseLabels();
const [tab, setTab] = useState<TabKey>('all');
const [selectedId, setSelectedId] = useState<string | null>(
applications[0]?.id ?? null,
);
const counts = useMemo(
() => ({
all: applications.length,
review: applications.filter((a) => a.status === 'UNDER_REVIEW').length,
approved: applications.filter(
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
).length,
pending: applications.filter((a) =>
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
).length,
}),
[applications],
);
const filtered = useMemo(() => {
switch (tab) {
case 'review':
return applications.filter((a) => a.status === 'UNDER_REVIEW');
case 'approved':
return applications.filter(
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
);
case 'pending':
return applications.filter((a) =>
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
);
default:
return applications;
}
}, [applications, tab]);
const selected =
applications.find((a) => a.id === selectedId) ?? filtered[0] ?? applications[0];
const TABS: { key: TabKey; label: string; count: number }[] = [
{ key: 'all', label: 'All', count: counts.all },
{ key: 'review', label: 'In Review', count: counts.review },
{ key: 'approved', label: 'Approved', count: counts.approved },
{ key: 'pending', label: 'Pending', count: counts.pending },
];
return (
<Stack gap="lg">
<Group justify="space-between">
<Group gap="xs">
{TABS.map((tabItem) => {
const active = tab === tabItem.key;
return (
<Button
key={tabItem.key}
size="xs"
radius="xl"
variant={active ? 'filled' : 'default'}
onClick={() => setTab(tabItem.key)}
rightSection={
<Badge
size="sm"
radius="xl"
variant={active ? 'white' : 'light'}
color={active ? 'emaPrimary' : 'gray'}
>
{tabItem.count}
</Badge>
}
>
{tabItem.label}
</Button>
);
})}
</Group>
<Button
variant="default"
size="xs"
radius="md"
leftSection={<IconAdjustmentsHorizontal size={16} />}
>
Filter &amp; sort
</Button>
</Group>
<Grid gutter="lg" align="start">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper withBorder radius="lg" p="xs">
<Table verticalSpacing="md" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Application</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Fee</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={36} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((app) => {
const isSelected = selected?.id === app.id;
return (
<Table.Tr
key={app.id}
onClick={() => setSelectedId(app.id)}
style={{
cursor: 'pointer',
background: isSelected
? 'var(--mantine-color-emaPrimary-light)'
: undefined,
}}
>
<Table.Td>
<Text fw={600} fz="sm">
{app.referenceNo}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">
{licenseType(app.licenseType)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">
{formatDate(app.submittedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" fw={500}>
{etb(SERVICE_FEE[app.licenseType])}
</Text>
</Table.Td>
<Table.Td>
<StatusPill status={app.status} />
</Table.Td>
<Table.Td>
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
{selected && <TimelinePanel application={selected} />}
</Grid.Col>
</Grid>
</Stack>
);
}
function TimelinePanel({ application }: { application: Application }) {
const { licenseType, formatDate } = useLicenseLabels();
const current = stageIndex(application.status);
const historyDates = new Map(
application.history.map((h) => [h.status, h.date] as const),
);
return (
<Stack gap="lg">
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="sm">
<div>
<Text fw={700}>{application.referenceNo}</Text>
<Text size="sm" c="dimmed">
{licenseType(application.licenseType)}
</Text>
</div>
<StatusPill status={application.status} />
</Group>
<Divider mb="lg" />
<Timeline active={current} bulletSize={26} lineWidth={2} color="emaPrimary">
{APPLICATION_PIPELINE.map((status, i) => {
const done = i < current;
const isCurrent = i === current;
const date = historyDates.get(status);
return (
<Timeline.Item
key={status}
bullet={done ? <IconCheck size={14} /> : undefined}
title={
<Text fw={isCurrent || done ? 600 : 500} fz="sm">
{PIPELINE_LABELS[status]}
</Text>
}
lineVariant={done ? 'solid' : 'dashed'}
>
<Text
fz="xs"
c={isCurrent ? 'emaPrimary' : 'dimmed'}
fw={isCurrent ? 600 : 400}
>
{date
? formatDate(date)
: isCurrent
? 'In progress'
: 'Pending'}
</Text>
</Timeline.Item>
);
})}
</Timeline>
<Group
gap="sm"
mt="lg"
p="sm"
wrap="nowrap"
style={{
borderRadius: 'var(--mantine-radius-md)',
background: 'var(--mantine-color-indigo-light)',
}}
>
<ThemeIcon variant="transparent" color="indigo" size="sm">
<IconClock size={17} />
</ThemeIcon>
<Text fz="sm" fw={500} c="indigo">
Last updated {formatDate(application.updatedAt)}
</Text>
</Group>
</Paper>
<Paper withBorder radius="lg" p="sm">
<Stack gap={4}>
<ActionRow
icon={<IconDownload size={18} />}
label="Download submission receipt"
onClick={() => notify.info('Receipt download — coming soon.')}
/>
<ActionRow
icon={<IconMessageCircle size={18} />}
label="Message case officer"
onClick={() => notify.info('Messaging — coming soon.')}
/>
<ActionRow
icon={<IconCircleX size={18} />}
label="Withdraw application"
danger
onClick={() => notify.info('Withdrawal — coming soon.')}
/>
</Stack>
</Paper>
</Stack>
);
}
function ActionRow({
icon,
label,
onClick,
danger,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<UnstyledButton onClick={onClick} p="xs" style={{ borderRadius: 'var(--mantine-radius-sm)' }}>
<Group gap="sm" wrap="nowrap">
<Box c={danger ? 'red' : 'dimmed'} style={{ display: 'flex' }}>
{icon}
</Box>
<Text fz="sm" fw={500} flex={1} c={danger ? 'red' : undefined}>
{label}
</Text>
<IconChevronRight size={15} style={{ opacity: 0.4 }} />
</Group>
</UnstyledButton>
);
}

View File

@@ -1,468 +0,0 @@
import { useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Center,
Checkbox,
Divider,
Grid,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Stepper,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCircleCheck,
IconCloudUpload,
IconDeviceMobile,
IconId,
IconInfoCircle,
IconLifebuoy,
IconMapPin,
IconMessageCircle,
IconShip,
IconStack2,
IconUser,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { useLicenses } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
import {
LICENSE_PROCESSING_DAYS,
LICENSE_TYPE_LABELS,
LICENSE_VALIDITY_YEARS,
REQUEST_TYPE_LABELS,
REQUIRED_DOCUMENTS,
} from '../constants';
import type { LicenseType, RequestType } from '../types/license.types';
const SERVICE_FEE: Record<LicenseType, number> = {
SEAFARER_COC: 1200,
SEAFARER_COP: 900,
SEAMAN_BOOK: 600,
VESSEL_REGISTRATION: 3500,
SHIP_RADIO: 900,
TONNAGE_CERTIFICATE: 2400,
SAFETY_MANAGEMENT: 5000,
PORT_FACILITY: 5000,
BOAT_OPERATOR: 700,
};
const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other'];
const REGIONS = [
'Addis Ababa',
'Dire Dawa',
'Amhara',
'Oromia',
'Tigray',
'Afar',
'Somali',
'Sidama',
'South Ethiopia',
'Gambela',
'Benishangul-Gumuz',
'Harari',
];
const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({
value: v,
label: LICENSE_TYPE_LABELS[v],
}));
const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({
value: v,
label: REQUEST_TYPE_LABELS[v],
}));
export function ApplyPage() {
const navigate = useNavigate();
const { submit } = useLicenses();
const { subjectLabel, doc } = useLicenseLabels();
const [active, setActive] = useState(0);
const [licenseType, setLicenseType] = useState<LicenseType | null>('SEAFARER_COC');
const [category, setCategory] = useState<string | null>('Deck Officer');
const [region, setRegion] = useState<string | null>('Addis Ababa');
const [requestType, setRequestType] = useState<RequestType>('NEW');
const [fullName, setFullName] = useState('');
const [idNumber, setIdNumber] = useState('');
const [phone, setPhone] = useState('');
const [subjectName, setSubjectName] = useState('');
const [notes, setNotes] = useState('');
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
const requiredDocs = useMemo(
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
[licenseType],
);
const attached = requiredDocs.filter((d) => uploaded[d]).length;
const next = () => setActive((c) => Math.min(c + 1, 3));
const prev = () => setActive((c) => Math.max(c - 1, 0));
const canContinue = () => {
if (active === 0) return !!licenseType && !!fullName.trim();
if (active === 1) return !!subjectName.trim();
return true;
};
const handleSubmit = () => {
if (!licenseType) return;
submit({
requestType,
licenseType,
applicantName: fullName.trim(),
subjectName: subjectName.trim() || fullName.trim(),
notes: notes.trim() || undefined,
documents: requiredDocs.filter((d) => uploaded[d]),
});
notify.success('Your application has been submitted to EMA.');
navigate('/applications');
};
return (
<Stack gap="lg">
<Paper withBorder radius="lg" p="lg">
<Stepper active={active} onStepClick={setActive} size="sm" iconSize={34}>
<Stepper.Step label="Service & Applicant" description="Step 1" />
<Stepper.Step label="License Details" description="Step 2" />
<Stepper.Step label="Documents" description="Step 3" />
<Stepper.Step label="Review & Submit" description="Step 4" />
</Stepper>
</Paper>
<Grid gutter="lg" align="start">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper withBorder radius="lg" p="xl">
{active === 0 && (
<Stack gap="md">
<SectionHead
title="Service & Applicant Details"
subtitle="Choose the license you need and tell us who the application is for."
/>
<Select
label="License type"
leftSection={<IconShip size={18} />}
data={LICENSE_OPTIONS}
value={licenseType}
onChange={(v) => setLicenseType(v as LicenseType)}
searchable
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Category"
leftSection={<IconStack2 size={18} />}
data={CATEGORIES}
value={category}
onChange={setCategory}
/>
<Select
label="Region"
leftSection={<IconMapPin size={18} />}
data={REGIONS}
value={region}
onChange={setRegion}
searchable
/>
</SimpleGrid>
<TextInput
label="Full name (as on ID)"
leftSection={<IconUser size={18} />}
placeholder="Abebe Bekele Tadesse"
value={fullName}
onChange={(e) => setFullName(e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="National ID / Passport"
leftSection={<IconId size={18} />}
placeholder="ET-1234567"
value={idNumber}
onChange={(e) => setIdNumber(e.currentTarget.value)}
/>
<TextInput
label="Phone number"
leftSection={<IconDeviceMobile size={18} />}
placeholder="+251 911 234 567"
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
</SimpleGrid>
<Dropzone />
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<SectionHead
title="License Details"
subtitle="A few more details about this specific request."
/>
<Select
label="Request type"
data={REQUEST_OPTIONS}
value={requestType}
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
allowDeselect={false}
/>
<TextInput
label={licenseType ? subjectLabel(licenseType) : 'Subject'}
placeholder={licenseType ? subjectLabel(licenseType) : 'Subject'}
value={subjectName}
onChange={(e) => setSubjectName(e.currentTarget.value)}
/>
<Textarea
label="Additional notes (optional)"
placeholder="Anything the reviewing officer should know"
autosize
minRows={3}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
</Stack>
)}
{active === 2 && (
<Stack gap="sm">
<SectionHead
title="Required Documents"
subtitle="Tick each document once you have attached it."
/>
<Alert variant="light" color="emaPrimary" icon={<IconInfoCircle size={18} />}>
All required documents must be provided before the review can be completed.
</Alert>
{requiredDocs.map((d) => (
<Checkbox
key={d}
label={doc(d)}
checked={!!uploaded[d]}
onChange={() =>
setUploaded((s) => ({ ...s, [d]: !s[d] }))
}
/>
))}
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<SectionHead
title="Review & Submit"
subtitle="Confirm the details below, then submit to EMA."
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<ReviewItem label="License type" value={licenseType ? LICENSE_TYPE_LABELS[licenseType] : '—'} />
<ReviewItem label="Request type" value={REQUEST_TYPE_LABELS[requestType]} />
<ReviewItem label="Full name" value={fullName || '—'} />
<ReviewItem label="Region" value={region ?? '—'} />
<ReviewItem label="Subject" value={subjectName || '—'} />
<ReviewItem
label="Documents attached"
value={`${attached} of ${requiredDocs.length}`}
/>
</SimpleGrid>
{attached < requiredDocs.length && (
<Alert variant="light" color="orange">
Some required documents are still missing. You can still submit, but
review may be delayed.
</Alert>
)}
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
leftSection={<IconArrowLeft size={18} />}
onClick={prev}
disabled={active === 0}
>
Back
</Button>
{active < 3 ? (
<Button
rightSection={<IconArrowRight size={18} />}
onClick={next}
disabled={!canContinue()}
>
Continue
</Button>
) : (
<Button
color="teal"
leftSection={<IconCircleCheck size={18} />}
onClick={handleSubmit}
>
Submit application
</Button>
)}
</Group>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Application Summary
</Title>
<Group
gap="sm"
wrap="nowrap"
p="sm"
mb="md"
style={{
borderRadius: 'var(--mantine-radius-md)',
background: 'var(--mantine-color-emaPrimary-light)',
}}
>
<ThemeIcon variant="filled" color="emaPrimary" size={40} radius="md">
<IconShip size={21} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz="sm" truncate>
{licenseType ? LICENSE_TYPE_LABELS[licenseType] : 'Select a license'}
</Text>
<Text fz="xs" c="dimmed">
{category ?? '—'} · {REQUEST_TYPE_LABELS[requestType]}
</Text>
</Box>
</Group>
<Stack gap="sm">
<SummaryRow
label="Processing time"
value={
licenseType
? `${LICENSE_PROCESSING_DAYS[licenseType][0]}${LICENSE_PROCESSING_DAYS[licenseType][1]} working days`
: '—'
}
/>
<SummaryRow
label="License validity"
value={
licenseType ? `${LICENSE_VALIDITY_YEARS[licenseType]} years` : '—'
}
/>
<SummaryRow
label="Service fee"
value={licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
/>
</Stack>
<Divider my="md" />
<Group justify="space-between" align="center">
<Text fw={700}>Total payable</Text>
<Text fw={700} fz="lg" c="emaPrimary">
{licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
</Text>
</Group>
</Paper>
<Paper radius="lg" p="lg" bg="var(--mantine-color-emaTeal-light)">
<Group gap="sm" mb="xs">
<ThemeIcon variant="filled" color="emaTeal.8" size={36} radius="md">
<IconLifebuoy size={19} />
</ThemeIcon>
<Text fw={700} c="emaTeal.8">
Need help?
</Text>
</Group>
<Text fz="sm" c="dimmed" mb="md" lh={1.55}>
Our support team can guide you through the documents required for this
license.
</Text>
<Button
fullWidth
variant="white"
color="emaTeal.8"
leftSection={<IconMessageCircle size={16} />}
onClick={() => navigate('/support')}
>
Contact support
</Button>
</Paper>
</Stack>
</Grid.Col>
</Grid>
</Stack>
);
}
function SectionHead({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div>
<Title order={4}>{title}</Title>
<Text size="sm" c="dimmed" mt={4}>
{subtitle}
</Text>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="center" wrap="nowrap">
<Text fz="sm" c="dimmed">
{label}
</Text>
<Text fz="sm" fw={600} ta="right">
{value}
</Text>
</Group>
);
}
function ReviewItem({ label, value }: { label: string; value: string }) {
return (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm" mt={2}>
{value}
</Text>
</div>
);
}
function Dropzone() {
return (
<div>
<Text fz="sm" fw={600} c="dimmed" mb={6}>
Supporting document
</Text>
<Center
p="xl"
style={{
border: '1.5px dashed var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-md)',
background: 'var(--mantine-color-default-hover)',
cursor: 'pointer',
}}
onClick={() => notify.info('File upload — coming soon.')}
>
<Stack gap={8} align="center">
<ThemeIcon variant="light" color="emaPrimary" size={46} radius="xl">
<IconCloudUpload size={23} />
</ThemeIcon>
<Text fz="sm" fw={600}>
Drag &amp; drop files here, or click to browse
</Text>
<Text fz="xs" c="dimmed">
PDF, JPG or PNG up to 10 MB
</Text>
</Stack>
</Center>
</div>
);
}

View File

@@ -1,158 +0,0 @@
import {
Alert,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCertificate,
IconDownload,
IconRefresh,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { PageHeader } from '../../../components/PageHeader';
import { EmptyState } from '../../../components/EmptyState';
import { LicenseStatusBadge } from '../components/StatusBadge';
import { useLicense } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
export function LicenseDetailPage() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const license = useLicense(id);
const { licenseType, formatDate } = useLicenseLabels();
if (!license) {
return (
<EmptyState
icon={IconCertificate}
title={t('common.notFound')}
action={{
label: t('common.backToList'),
onClick: () => navigate('/licenses'),
}}
/>
);
}
const daysLeft = Math.ceil(
(new Date(license.expiresAt).getTime() - Date.now()) / 86_400_000,
);
return (
<Stack gap="lg">
<Button
variant="subtle"
size="sm"
leftSection={<IconArrowLeft size={16} />}
onClick={() => navigate('/licenses')}
w="fit-content"
>
{t('common.backToList')}
</Button>
<PageHeader
title={license.licenseNo}
subtitle={licenseType(license.licenseType)}
action={<LicenseStatusBadge status={license.status} />}
/>
{license.status === 'EXPIRED' && (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={18} />}>
{t('licenses.detail.expired')}
</Alert>
)}
{license.status === 'EXPIRING_SOON' && (
<Alert color="orange" variant="light" icon={<IconAlertTriangle size={18} />}>
{t('licenses.detail.expiringSoon')}
</Alert>
)}
<Card withBorder radius="md" padding="xl">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon
size={56}
radius="md"
variant="gradient"
gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
>
<IconCertificate size={30} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">
{licenseType(license.licenseType)}
</Text>
<Text c="dimmed" size="sm">
{t('app.authority')}
</Text>
</div>
</Group>
{license.status !== 'EXPIRED' && daysLeft > 0 && (
<Text c="dimmed" size="sm" ta="right">
{t('licenses.detail.daysLeft', { count: daysLeft })}
</Text>
)}
</Group>
<Divider my="lg" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Field label={t('licenses.detail.holder')} value={license.holderName} />
<Field label={t('licenses.detail.subject')} value={license.subjectName} />
<Field
label={t('licenses.detail.issuedOn')}
value={formatDate(license.issuedAt)}
/>
<Field
label={t('licenses.detail.expiresOn')}
value={formatDate(license.expiresAt)}
/>
</SimpleGrid>
</Card>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Group>
<Button
leftSection={<IconDownload size={18} />}
onClick={() => notify.info(t('common.download'))}
>
{t('licenses.detail.download')}
</Button>
<Button
variant="light"
leftSection={<IconRefresh size={18} />}
onClick={() => navigate(`/apply?type=${license.licenseType}&request=RENEWAL`)}
>
{t('licenses.detail.renew')}
</Button>
</Group>
</Paper>
</Stack>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm" mt={2}>
{value}
</Text>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { Paper, Stack } from '@mantine/core';
import { IconCertificate } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
import { EmptyState } from '../../../components/EmptyState';
import { LicensesTable } from '../components/LicensesTable';
import { useLicenses } from '../hooks/useLicenses';
export function MyLicensesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { licenses } = useLicenses();
return (
<Stack gap="lg">
<PageHeader title={t('licenses.title')} subtitle={t('licenses.subtitle')} />
{licenses.length ? (
<Paper p="md" shadow="sm" radius="md" withBorder>
<LicensesTable licenses={licenses} />
</Paper>
) : (
<EmptyState
icon={IconCertificate}
title={t('licenses.empty')}
action={{
label: t('dashboard.newApplication'),
onClick: () => navigate('/apply'),
}}
/>
)}
</Stack>
);
}

View File

@@ -1,39 +0,0 @@
import { SimpleGrid, Stack, Title } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
import { ServiceCard } from '../components/ServiceCard';
import {
LICENSE_CATEGORY,
type ServiceCategory,
} from '../constants';
import type { LicenseType } from '../types/license.types';
const CATEGORY_ORDER: ServiceCategory[] = ['seafarer', 'vessel', 'facility'];
export function ServicesPage() {
const { t } = useTranslation();
const grouped = CATEGORY_ORDER.map((category) => ({
category,
types: (Object.keys(LICENSE_CATEGORY) as LicenseType[]).filter(
(type) => LICENSE_CATEGORY[type] === category,
),
})).filter((g) => g.types.length > 0);
return (
<Stack gap="xl">
<PageHeader title={t('services.title')} subtitle={t('services.subtitle')} />
{grouped.map(({ category, types }) => (
<Stack key={category} gap="sm">
<Title order={4}>{t(`services.categoryLabel.${category}`)}</Title>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{types.map((type) => (
<ServiceCard key={type} type={type} />
))}
</SimpleGrid>
</Stack>
))}
</Stack>
);
}

View File

@@ -1,172 +0,0 @@
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit';
import { REQUIRED_DOCUMENTS } from '../constants';
import type {
Application,
License,
NewApplicationInput,
} from '../types/license.types';
interface LicensesState {
applications: Application[];
licenses: License[];
}
const daysFromNow = (days: number): string => {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
};
const seedApplications: Application[] = [
{
id: 'app-1001',
referenceNo: 'EMA-2026-001042',
requestType: 'NEW',
licenseType: 'SEAFARER_COC',
applicantName: 'Dawit Bekele',
subjectName: 'Dawit Bekele',
status: 'UNDER_REVIEW',
submittedAt: daysFromNow(-9),
updatedAt: daysFromNow(-2),
documents: [
{ name: 'Passport copy', status: 'VERIFIED' },
{ name: 'Training certificate', status: 'VERIFIED' },
{ name: 'Medical fitness certificate', status: 'UPLOADED' },
{ name: 'Sea service record', status: 'UPLOADED' },
],
history: [
{ status: 'SUBMITTED', date: daysFromNow(-9), note: 'Application received by EMA.' },
{ status: 'UNDER_REVIEW', date: daysFromNow(-2), note: 'Assigned to licensing officer for review.' },
],
},
{
id: 'app-1002',
referenceNo: 'EMA-2026-000987',
requestType: 'NEW',
licenseType: 'VESSEL_REGISTRATION',
applicantName: 'Blue Nile Shipping PLC',
subjectName: 'MV Abay Star',
status: 'INFO_REQUESTED',
submittedAt: daysFromNow(-15),
updatedAt: daysFromNow(-4),
notes: 'Tonnage measurement document is illegible — please re-upload a clear scan.',
documents: [
{ name: 'Bill of sale', status: 'VERIFIED' },
{ name: 'Builder certificate', status: 'VERIFIED' },
{ name: 'Tonnage measurement', status: 'REJECTED' },
{ name: 'Insurance certificate', status: 'UPLOADED' },
],
history: [
{ status: 'SUBMITTED', date: daysFromNow(-15), note: 'Application received by EMA.' },
{ status: 'UNDER_REVIEW', date: daysFromNow(-10), note: 'Document verification in progress.' },
{ status: 'INFO_REQUESTED', date: daysFromNow(-4), note: 'Additional information requested from applicant.' },
],
},
{
id: 'app-1003',
referenceNo: 'EMA-2026-000654',
requestType: 'RENEWAL',
licenseType: 'SHIP_RADIO',
applicantName: 'Blue Nile Shipping PLC',
subjectName: 'MV Abay Star',
status: 'ISSUED',
submittedAt: daysFromNow(-40),
updatedAt: daysFromNow(-25),
relatedLicenseNo: 'EMA-SR-2021-0231',
documents: [
{ name: 'Vessel registration', status: 'VERIFIED' },
{ name: 'Equipment specification', status: 'VERIFIED' },
{ name: 'Operator certificate', status: 'VERIFIED' },
],
history: [
{ status: 'SUBMITTED', date: daysFromNow(-40), note: 'Renewal application received.' },
{ status: 'UNDER_REVIEW', date: daysFromNow(-35), note: 'Under review.' },
{ status: 'PAYMENT_PENDING', date: daysFromNow(-30), note: 'Service fee invoice issued.' },
{ status: 'APPROVED', date: daysFromNow(-27), note: 'Application approved.' },
{ status: 'ISSUED', date: daysFromNow(-25), note: 'Licence issued and available for download.' },
],
},
];
const seedLicenses: License[] = [
{
id: 'lic-1',
licenseNo: 'EMA-SR-2021-0231',
licenseType: 'SHIP_RADIO',
holderName: 'Blue Nile Shipping PLC',
subjectName: 'MV Abay Star',
status: 'ACTIVE',
issuedAt: daysFromNow(-25),
expiresAt: daysFromNow(340),
},
{
id: 'lic-2',
licenseNo: 'EMA-CC-2019-0098',
licenseType: 'SEAFARER_COC',
holderName: 'Dawit Bekele',
subjectName: 'Dawit Bekele',
status: 'EXPIRING_SOON',
issuedAt: daysFromNow(-1800),
expiresAt: daysFromNow(48),
},
{
id: 'lic-3',
licenseNo: 'EMA-VR-2018-0455',
licenseType: 'VESSEL_REGISTRATION',
holderName: 'Blue Nile Shipping PLC',
subjectName: 'MV Tana',
status: 'EXPIRED',
issuedAt: daysFromNow(-2600),
expiresAt: daysFromNow(-60),
},
];
const initialState: LicensesState = {
applications: seedApplications,
licenses: seedLicenses,
};
const year = new Date().getFullYear();
const licensesSlice = createSlice({
name: 'licenses',
initialState,
reducers: {
submitApplication: {
reducer(state, action: PayloadAction<Application>) {
state.applications.unshift(action.payload);
},
prepare(input: NewApplicationInput) {
const now = new Date().toISOString();
const seq = Math.floor(100000 + Math.random() * 899999);
const referenceNo = `EMA-${year}-${seq}`;
const documents = (input.documents.length
? input.documents
: REQUIRED_DOCUMENTS[input.licenseType]
).map((name) => ({ name, status: 'UPLOADED' as const }));
const application: Application = {
id: `app-${nanoid(8)}`,
referenceNo,
requestType: input.requestType,
licenseType: input.licenseType,
applicantName: input.applicantName,
subjectName: input.subjectName,
relatedLicenseNo: input.relatedLicenseNo,
notes: input.notes,
status: 'SUBMITTED',
submittedAt: now,
updatedAt: now,
documents,
history: [
{ status: 'SUBMITTED', date: now, note: 'Application received by EMA.' },
],
};
return { payload: application };
},
},
},
});
export const { submitApplication } = licensesSlice.actions;
export const licensesReducer = licensesSlice.reducer;

View File

@@ -1,82 +0,0 @@
// Domain model for the Ethiopian Maritime Authority (EMA) licensing portal.
/** Categories of licence / certificate the Authority issues. */
export type LicenseType =
| 'SEAFARER_COC' // Certificate of Competency
| 'SEAFARER_COP' // Certificate of Proficiency
| 'SEAMAN_BOOK' // Seafarer's Identity & Record Book
| 'VESSEL_REGISTRATION' // Vessel / Ship Registration Certificate
| 'SHIP_RADIO' // Ship Station Radio Licence
| 'TONNAGE_CERTIFICATE' // International Tonnage Certificate
| 'SAFETY_MANAGEMENT' // Safety Management Certificate
| 'PORT_FACILITY' // Port Facility Operation Licence
| 'BOAT_OPERATOR'; // Inland Boat Operator Licence
/** What the applicant is asking the Authority to do. */
export type RequestType = 'NEW' | 'RENEWAL' | 'AMENDMENT' | 'COMPLIANCE';
/** Lifecycle of a submitted application. */
export type ApplicationStatus =
| 'DRAFT'
| 'SUBMITTED'
| 'UNDER_REVIEW'
| 'INFO_REQUESTED'
| 'PAYMENT_PENDING'
| 'APPROVED'
| 'REJECTED'
| 'ISSUED';
/** Health of an already-issued licence. */
export type LicenseStatus = 'ACTIVE' | 'EXPIRING_SOON' | 'EXPIRED' | 'SUSPENDED';
export type DocumentStatus = 'PENDING' | 'UPLOADED' | 'VERIFIED' | 'REJECTED';
export interface ApplicationDocument {
name: string;
status: DocumentStatus;
}
export interface TimelineEvent {
status: ApplicationStatus;
date: string; // ISO date
note: string;
}
export interface Application {
id: string;
referenceNo: string;
requestType: RequestType;
licenseType: LicenseType;
applicantName: string;
/** Vessel name or seafarer subject, depending on licence type. */
subjectName: string;
status: ApplicationStatus;
submittedAt: string; // ISO date
updatedAt: string; // ISO date
/** Set once an amendment / compliance request targets an existing licence. */
relatedLicenseNo?: string;
notes?: string;
documents: ApplicationDocument[];
history: TimelineEvent[];
}
export interface License {
id: string;
licenseNo: string;
licenseType: LicenseType;
holderName: string;
subjectName: string;
status: LicenseStatus;
issuedAt: string; // ISO date
expiresAt: string; // ISO date
}
export interface NewApplicationInput {
requestType: RequestType;
licenseType: LicenseType;
applicantName: string;
subjectName: string;
relatedLicenseNo?: string;
notes?: string;
documents: string[];
}

View File

@@ -1,7 +1,3 @@
// Amharic (አማርኛ) translations for the EMA portal.
// Mirrors the key structure of en.ts. Maritime/government terminology follows
// common usage of the Ethiopian Maritime Authority.
import type { Translations } from './en';
export const am: Translations = {
@@ -19,10 +15,7 @@ export const am: Translations = {
nav: {
dashboard: 'ዳሽቦርድ',
services: 'አገልግሎቶች',
apply: 'አዲስ ማመልከቻ',
applications: 'ማመልከቻዎቼ',
licenses: 'ፍቃዶቼ',
seafarerRegistry: 'የመርከበኞች ምዝገባ',
profile: 'መገለጫ',
support: 'እገዛና ድጋፍ',
},
@@ -39,9 +32,6 @@ export const am: Translations = {
search: 'ፈልግ',
download: 'አውርድ',
print: 'አትም',
renew: 'አድስ',
apply: 'አመልክት',
applyNow: 'አሁን አመልክት',
filter: 'አጣራ',
all: 'ሁሉም',
status: 'ሁኔታ',
@@ -68,159 +58,8 @@ export const am: Translations = {
},
dashboard: {
title: 'ዳሽቦር',
subtitle: 'የፍቃዶችዎ፣ የማመልከቻዎችዎና የተገዢነት ሁኔታ አጠቃላይ እይታ።',
newApplication: 'አዲስ ማመልከቻ',
recentApplications: 'የቅርብ ጊዜ ማመልከቻዎች',
applicationsByStatus: 'ማመልከቻዎች በሁኔታ',
title: 'ዳሽቦር',
quickActions: 'ፈጣን ድርጊቶች',
stats: {
activeLicenses: 'ንቁ ፍቃዶች',
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
expiringSoon: 'በቅርቡ የሚያበቁ',
actionRequired: 'እርምጃ የሚፈልጉ',
},
quick: {
apply: 'ለፍቃድ ያመልክቱ',
applyDesc: 'አዲስ ማመልከቻ ይጀምሩ',
renew: 'ፍቃድ ያድሱ',
renewDesc: 'የምስክር ወረቀቶችዎን የጸኑ ያድርጉ',
track: 'ማመልከቻ ይከታተሉ',
trackDesc: 'ጥያቄዎ የት እንዳለ ይመልከቱ',
},
hero: {
greeting: 'እንኳን ደህና መጡ',
subtitle:
'ለባሕር ፍቃዶች ያመልክቱ፣ ማመልከቻዎችዎን ይከታተሉ እና የምስክር ወረቀቶችዎን ያስተዳድሩ — ሁሉም በአንድ ቦታ።',
browse: 'አገልግሎቶችን ይመልከቱ',
},
charts: {
trend: 'ማመልከቻዎች በጊዜ ሂደት',
trendPeriod: 'ያለፉት 6 ወራት',
distribution: 'የሁኔታ ስርጭት',
noData: 'እስካሁን የሚታይ መረጃ የለም',
total: 'ጠቅላላ',
},
},
services: {
title: 'የባሕር አገልግሎቶች',
subtitle:
'በየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን የሚሰጡ ፍቃዶችንና የምስክር ወረቀቶችን ይመልከቱና ማመልከቻ ይጀምሩ።',
seafarer: 'የመርከበኛ ምስክርነት',
vessel: 'የመርከብ አገልግሎቶች',
facility: 'ወደብና ተቋም',
requiredDocuments: 'የሚያስፈልጉ ሰነዶች',
processingTime: 'መደበኛ የማስኬጃ ጊዜ',
validity: 'የጸናበት ጊዜ',
apply: 'አመልክት',
processingDays: '{{min}}{{max}} የሥራ ቀናት',
validityYears_one: '{{count}} ዓመት',
validityYears_other: '{{count}} ዓመታት',
categoryLabel: {
seafarer: 'የመርከበኛ ምስክርነት',
vessel: 'የመርከብ አገልግሎቶች',
facility: 'ወደብና ተቋም',
},
},
apply: {
title: 'ለፍቃድ ማመልከት',
subtitle:
'ማመልከቻዎን ለየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ለማስገባት ከታች ያሉትን ደረጃዎች ይሙሉ።',
steps: {
license: 'ፍቃድ',
licenseDesc: 'ዓይነትና ጥያቄ',
details: 'ዝርዝሮች',
detailsDesc: 'የአመልካች መረጃ',
documents: 'ሰነዶች',
documentsDesc: 'የሚያስፈልጉ ማስገቢያዎች',
review: 'ግምገማ',
},
fields: {
requestType: 'የጥያቄ ዓይነት',
licenseType: 'የፍቃድ ዓይነት',
licenseTypePlaceholder: 'የሚፈልጉትን ፍቃድ ይምረጡ',
applicantName: 'የአመልካች / ድርጅት ስም',
applicantPlaceholder: 'ለምሳሌ ብሉ ናይል ሺፒንግ ኃ.የተ.የግ.ማ.',
notes: 'ተጨማሪ ማስታወሻ',
notesPlaceholder: 'ገምጋሚው መኮንን ሊያውቀው የሚገባ ማንኛውም ነገር',
relatedLicense: 'ነባር የፍቃድ ቁጥር',
},
docs: {
hint: 'እያንዳንዱን ሰነድ ካያያዙ በኋላ ምልክት ያድርጉ። ከማስገባትዎ በፊት ሁሉም የሚያስፈልጉ ሰነዶች መቅረብ አለባቸው።',
attached: 'የተያያዙ ሰነዶች',
attachedCount: '{{count}} ከ{{total}} የሚያስፈልጉ ሰነዶች',
},
review: {
title: 'ማመልከቻዎን ይገምግሙ',
hint: 'ከታች ያሉትን ዝርዝሮች ያረጋግጡና ለኢባባ ያስገቡ።',
missingDocs:
'አንዳንድ የሚያስፈልጉ ሰነዶች አሁንም ይጎድላሉ። አሁንም ማስገባት ይችላሉ፣ ነገር ግን ግምገማው ሊዘገይ ይችላል።',
},
submitted: 'ማመልከቻዎ ለኢባባ ቀርቧል።',
submitAction: 'ማመልከቻ አስገባ',
},
applications: {
title: 'ማመልከቻዎቼ',
subtitle: 'ለኢባባ ያስገቡትን እያንዳንዱን ጥያቄ ሁኔታ ይከታተሉ።',
empty: 'እስካሁን ምንም ማመልከቻ የለም። ለፍቃድ በማመልከት ይጀምሩ።',
searchPlaceholder: 'በማመሳከሪያ፣ በርዕሰ ጉዳይ ወይም በአመልካች ይፈልጉ',
columns: {
reference: 'የማመሳከሪያ ቁ.',
licenseType: 'የፍቃድ ዓይነት',
request: 'ጥያቄ',
subject: 'ርዕሰ ጉዳይ',
submitted: 'የቀረበበት',
status: 'ሁኔታ',
},
},
applicationDetail: {
title: 'ማመልከቻ',
reference: 'የማመሳከሪያ ቁጥር',
overview: 'አጠቃላይ እይታ',
progress: 'ሂደት',
documents: 'ሰነዶች',
history: 'ታሪክ',
applicant: 'አመልካች',
subject: 'ርዕሰ ጉዳይ',
requestType: 'የጥያቄ ዓይነት',
licenseType: 'የፍቃድ ዓይነት',
submittedOn: 'የቀረበበት ቀን',
lastUpdated: 'መጨረሻ የተሻሻለበት',
officerNote: 'ከኢባባ የተሰጠ ማስታወሻ',
relatedLicense: 'ተዛማጅ ፍቃድ',
actionRequired: 'እርምጃ ያስፈልጋል',
actionRequiredDesc:
'ማመልከቻዎ ከመቀጠሉ በፊት ኢባባ ተጨማሪ መረጃ ጠይቋል።',
respond: 'ለጥያቄው ምላሽ ይስጡ',
},
licenses: {
title: 'ፍቃዶቼ',
subtitle: 'በኢባባ ለእርስዎ የተሰጡ ሁሉም የምስክር ወረቀቶችና ፍቃዶች።',
empty: 'እስካሁን የተሰጠዎት ፍቃድ የለም።',
columns: {
licenseNo: 'የፍቃድ ቁ.',
type: 'ዓይነት',
subject: 'ርዕሰ ጉዳይ',
issued: 'የተሰጠበት',
expires: 'የሚያበቃበት',
status: 'ሁኔታ',
},
detail: {
holder: 'የፍቃድ ባለቤት',
subject: 'ርዕሰ ጉዳይ',
issuedOn: 'የተሰጠበት ቀን',
expiresOn: 'የሚያበቃበት ቀን',
daysLeft: '{{count}} ቀናት ይቀራሉ',
expired: 'ይህ ፍቃድ ጊዜው አልፎበታል።',
expiringSoon: 'ይህ ፍቃድ በቅርቡ ያበቃል። ተገዢ ለመሆን ያድሱ።',
download: 'የምስክር ወረቀት አውርድ',
renew: 'ፍቃድ አድስ',
},
},
profile: {
@@ -319,86 +158,4 @@ export const am: Translations = {
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
},
},
// ---- Domain enums ----
licenseType: {
SEAFARER_COC: 'የብቃት ምስክር ወረቀት (CoC)',
SEAFARER_COP: 'የክህሎት ምስክር ወረቀት (CoP)',
SEAMAN_BOOK: 'የመርከበኛ መታወቂያና መዝገብ ደብተር',
VESSEL_REGISTRATION: 'የመርከብ ምዝገባ ምስክር ወረቀት',
SHIP_RADIO: 'የመርከብ ሬዲዮ ጣቢያ ፍቃድ',
TONNAGE_CERTIFICATE: 'ዓለም አቀፍ የመርከብ ጭነት መለኪያ ምስክር ወረቀት',
SAFETY_MANAGEMENT: 'የደህንነት አስተዳደር ምስክር ወረቀት',
PORT_FACILITY: 'የወደብ ተቋም ማንቀሳቀሻ ፍቃድ',
BOAT_OPERATOR: 'የውስጥ ውሃ ጀልባ አንቀሳቃሽ ፍቃድ',
},
subjectLabel: {
SEAFARER_COC: 'የመርከበኛ ሙሉ ስም',
SEAFARER_COP: 'የመርከበኛ ሙሉ ስም',
SEAMAN_BOOK: 'የመርከበኛ ሙሉ ስም',
VESSEL_REGISTRATION: 'የመርከብ ስም',
SHIP_RADIO: 'የመርከብ ስም',
TONNAGE_CERTIFICATE: 'የመርከብ ስም',
SAFETY_MANAGEMENT: 'የመርከብ ስም',
PORT_FACILITY: 'የተቋም ስም',
BOAT_OPERATOR: 'የአንቀሳቃሽ ሙሉ ስም',
},
requestType: {
NEW: 'አዲስ ማመልከቻ',
RENEWAL: 'እድሳት',
AMENDMENT: 'ማሻሻያ',
COMPLIANCE: 'የተገዢነት ማቅረቢያ',
},
applicationStatus: {
DRAFT: 'ረቂቅ',
SUBMITTED: 'ቀርቧል',
UNDER_REVIEW: 'በግምገማ ላይ',
INFO_REQUESTED: 'መረጃ ተጠይቋል',
PAYMENT_PENDING: 'ክፍያ በመጠባበቅ ላይ',
APPROVED: 'ጸድቋል',
REJECTED: 'ተቀባይነት አላገኘም',
ISSUED: 'ተሰጥቷል',
},
licenseStatus: {
ACTIVE: 'ንቁ',
EXPIRING_SOON: 'በቅርቡ የሚያበቃ',
EXPIRED: 'ጊዜው ያለፈበት',
SUSPENDED: 'ታግዷል',
},
documentStatus: {
PENDING: 'በመጠባበቅ ላይ',
UPLOADED: 'ተጭኗል',
VERIFIED: 'ተረጋግጧል',
REJECTED: 'ተቀባይነት አላገኘም',
},
documents: {
'Passport copy': 'የፓስፖርት ቅጂ',
'Passport photo': 'የፓስፖርት ፎቶ',
'Training certificate': 'የሥልጠና ምስክር ወረቀት',
'Course completion certificate': 'የኮርስ ማጠናቀቂያ ምስክር ወረቀት',
'Medical fitness certificate': 'የጤና ብቁነት ምስክር ወረቀት',
'Sea service record': 'የባሕር አገልግሎት መዝገብ',
'Police clearance': 'የፖሊስ ማረጋገጫ',
'ID copy': 'የመታወቂያ ቅጂ',
'Bill of sale': 'የሽያጭ ሰነድ',
'Builder certificate': 'የገንቢ ምስክር ወረቀት',
'Tonnage measurement': 'የጭነት መለኪያ',
'Insurance certificate': 'የመድን ምስክር ወረቀት',
'Vessel registration': 'የመርከብ ምዝገባ',
'Equipment specification': 'የመሣሪያ ዝርዝር',
'Operator certificate': 'የአንቀሳቃሽ ምስክር ወረቀት',
'Survey report': 'የዳሰሳ ሪፖርት',
'General arrangement plan': 'አጠቃላይ የአደረጃጀት ዕቅድ',
'Safety management manual': 'የደህንነት አስተዳደር መመሪያ',
'Audit report': 'የኦዲት ሪፖርት',
'Business licence': 'የንግድ ፍቃድ',
'Facility security assessment': 'የተቋም ደህንነት ግምገማ',
'Site plan': 'የቦታ ዕቅድ',
},
};

View File

@@ -1,8 +1,3 @@
// English translations for the EMA portal (user side).
// Enum keys (licenseType.*, applicationStatus.*, etc.) match the union values
// in features/licenses/types so components can resolve labels with
// t(`licenseType.${value}`).
export const en = {
app: {
name: 'EMA Portal',
@@ -18,10 +13,7 @@ export const en = {
nav: {
dashboard: 'Dashboard',
services: 'Services',
apply: 'New Application',
applications: 'My Applications',
licenses: 'My Licences',
seafarerRegistry: 'Seafarer Registry',
profile: 'Profile',
support: 'Help & Support',
},
@@ -38,9 +30,6 @@ export const en = {
search: 'Search',
download: 'Download',
print: 'Print',
renew: 'Renew',
apply: 'Apply',
applyNow: 'Apply now',
filter: 'Filter',
all: 'All',
status: 'Status',
@@ -55,7 +44,7 @@ export const en = {
notFound: 'Not found',
learnMore: 'Learn more',
toggleTheme: 'Toggle light / dark mode',
welcome: 'Welcome back',
welcome: 'Welcome',
},
auth: {
@@ -67,159 +56,8 @@ export const en = {
},
dashboard: {
title: 'My Dashboard',
subtitle: 'Overview of your licences, applications and compliance status.',
newApplication: 'New Application',
recentApplications: 'Recent Applications',
applicationsByStatus: 'Applications by status',
title: 'Dashboard',
quickActions: 'Quick actions',
stats: {
activeLicenses: 'Active Licences',
pendingApplications: 'Pending Applications',
expiringSoon: 'Expiring Soon',
actionRequired: 'Action Required',
},
quick: {
apply: 'Apply for a licence',
applyDesc: 'Start a new application',
renew: 'Renew a licence',
renewDesc: 'Keep your certificates valid',
track: 'Track an application',
trackDesc: 'See where your request stands',
},
hero: {
greeting: 'Welcome back',
subtitle:
'Apply for maritime licences, track your applications and manage your certificates — all in one place.',
browse: 'Browse services',
},
charts: {
trend: 'Applications over time',
trendPeriod: 'Last 6 months',
distribution: 'Status distribution',
noData: 'No data to display yet',
total: 'Total',
},
},
services: {
title: 'Maritime Services',
subtitle:
'Browse the licences and certificates issued by the Ethiopian Maritime Authority and start an application.',
seafarer: 'Seafarer certification',
vessel: 'Vessel & ship services',
facility: 'Port & facility',
requiredDocuments: 'Required documents',
processingTime: 'Typical processing time',
validity: 'Validity',
apply: 'Apply',
processingDays: '{{min}}{{max}} working days',
validityYears_one: '{{count}} year',
validityYears_other: '{{count}} years',
categoryLabel: {
seafarer: 'Seafarer certification',
vessel: 'Vessel & ship services',
facility: 'Port & facility',
},
},
apply: {
title: 'Apply for a Licence',
subtitle:
'Complete the steps below to submit your application to the Ethiopian Maritime Authority.',
steps: {
license: 'Licence',
licenseDesc: 'Type & request',
details: 'Details',
detailsDesc: 'Applicant info',
documents: 'Documents',
documentsDesc: 'Required uploads',
review: 'Review',
},
fields: {
requestType: 'Request type',
licenseType: 'Licence type',
licenseTypePlaceholder: 'Select the licence you need',
applicantName: 'Applicant / Company name',
applicantPlaceholder: 'e.g. Blue Nile Shipping PLC',
notes: 'Additional notes',
notesPlaceholder: 'Anything the reviewing officer should know',
relatedLicense: 'Existing licence number',
},
docs: {
hint: 'Tick each document once you have attached it. All required documents must be provided before submitting.',
attached: 'Documents attached',
attachedCount: '{{count}} of {{total}} required documents',
},
review: {
title: 'Review your application',
hint: 'Confirm the details below, then submit to EMA.',
missingDocs:
'Some required documents are still missing. You can still submit, but review may be delayed.',
},
submitted: 'Your application has been submitted to EMA.',
submitAction: 'Submit application',
},
applications: {
title: 'My Applications',
subtitle: 'Track the status of every request you have submitted to EMA.',
empty: 'No applications yet. Start by applying for a licence.',
searchPlaceholder: 'Search by reference, subject or applicant',
columns: {
reference: 'Reference No.',
licenseType: 'Licence Type',
request: 'Request',
subject: 'Subject',
submitted: 'Submitted',
status: 'Status',
},
},
applicationDetail: {
title: 'Application',
reference: 'Reference number',
overview: 'Overview',
progress: 'Progress',
documents: 'Documents',
history: 'History',
applicant: 'Applicant',
subject: 'Subject',
requestType: 'Request type',
licenseType: 'Licence type',
submittedOn: 'Submitted on',
lastUpdated: 'Last updated',
officerNote: 'Note from EMA',
relatedLicense: 'Related licence',
actionRequired: 'Action required',
actionRequiredDesc:
'EMA has requested more information before your application can proceed.',
respond: 'Respond to request',
},
licenses: {
title: 'My Licences',
subtitle: 'All certificates and licences issued to you by EMA.',
empty: 'You have no issued licences yet.',
columns: {
licenseNo: 'Licence No.',
type: 'Type',
subject: 'Subject',
issued: 'Issued',
expires: 'Expires',
status: 'Status',
},
detail: {
holder: 'Licence holder',
subject: 'Subject',
issuedOn: 'Issued on',
expiresOn: 'Expires on',
daysLeft: '{{count}} days remaining',
expired: 'This licence has expired.',
expiringSoon: 'This licence is expiring soon. Renew to stay compliant.',
download: 'Download certificate',
renew: 'Renew licence',
},
},
profile: {
@@ -318,88 +156,6 @@ export const en = {
a4: 'You will see an "Action required" notice on the application. Provide the requested document or detail to resume the review.',
},
},
// ---- Domain enums (keys match the type unions) ----
licenseType: {
SEAFARER_COC: 'Certificate of Competency (CoC)',
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
SHIP_RADIO: 'Ship Station Radio Licence',
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
SAFETY_MANAGEMENT: 'Safety Management Certificate',
PORT_FACILITY: 'Port Facility Operation Licence',
BOAT_OPERATOR: 'Inland Boat Operator Licence',
},
subjectLabel: {
SEAFARER_COC: 'Seafarer full name',
SEAFARER_COP: 'Seafarer full name',
SEAMAN_BOOK: 'Seafarer full name',
VESSEL_REGISTRATION: 'Vessel name',
SHIP_RADIO: 'Vessel name',
TONNAGE_CERTIFICATE: 'Vessel name',
SAFETY_MANAGEMENT: 'Vessel name',
PORT_FACILITY: 'Facility name',
BOAT_OPERATOR: 'Operator full name',
},
requestType: {
NEW: 'New Application',
RENEWAL: 'Renewal',
AMENDMENT: 'Amendment',
COMPLIANCE: 'Compliance Submission',
},
applicationStatus: {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
INFO_REQUESTED: 'Information Requested',
PAYMENT_PENDING: 'Payment Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected',
ISSUED: 'Issued',
},
licenseStatus: {
ACTIVE: 'Active',
EXPIRING_SOON: 'Expiring Soon',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
},
documentStatus: {
PENDING: 'Pending',
UPLOADED: 'Uploaded',
VERIFIED: 'Verified',
REJECTED: 'Rejected',
},
documents: {
'Passport copy': 'Passport copy',
'Passport photo': 'Passport photo',
'Training certificate': 'Training certificate',
'Course completion certificate': 'Course completion certificate',
'Medical fitness certificate': 'Medical fitness certificate',
'Sea service record': 'Sea service record',
'Police clearance': 'Police clearance',
'ID copy': 'ID copy',
'Bill of sale': 'Bill of sale',
'Builder certificate': 'Builder certificate',
'Tonnage measurement': 'Tonnage measurement',
'Insurance certificate': 'Insurance certificate',
'Vessel registration': 'Vessel registration',
'Equipment specification': 'Equipment specification',
'Operator certificate': 'Operator certificate',
'Survey report': 'Survey report',
'General arrangement plan': 'General arrangement plan',
'Safety management manual': 'Safety management manual',
'Audit report': 'Audit report',
'Business licence': 'Business licence',
'Facility security assessment': 'Facility security assessment',
'Site plan': 'Site plan',
},
};
export type Translations = typeof en;

View File

@@ -1,9 +1,7 @@
import {
ActionIcon,
Anchor,
AppShell,
Burger,
Card,
Group,
Indicator,
Menu,
@@ -17,13 +15,9 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconBell,
IconCertificate,
IconChevronLeft,
IconChevronRight,
IconFileDescription,
IconFolder,
IconLayoutDashboard,
IconLifebuoy,
IconList,
@@ -48,11 +42,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/applications', label: 'My Applications', icon: IconFileDescription },
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconList },
{ to: '/seafarer-registration', label: 'New Registration', icon: IconAnchor },
{ to: '/licenses', label: 'My Licenses', icon: IconCertificate },
{ label: 'Documents', icon: IconFolder, soon: true },
{ to: '/profile', label: 'Profile', icon: IconUser },
{ to: '/support', label: 'Support', icon: IconLifebuoy },
];
@@ -67,22 +57,10 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
year: 'numeric',
}),
},
'/applications': {
title: 'My Applications',
subtitle: 'Track the status of every application',
},
'/apply': {
title: 'Apply for a License',
subtitle: 'New application',
},
'/seafarer-registry': {
title: 'Seafarer Registry',
subtitle: 'Manage all registered seafarers',
},
'/seafarer-registration': {
title: 'New Seafarer Registration',
subtitle: 'Register a new seafarer profile',
},
'/profile': {
title: 'Profile',
subtitle: 'Manage your account and preferences',
@@ -120,66 +98,144 @@ export function PortalLayout() {
navigate('/login');
};
const activeNavItem = (item: NavItem) =>
!!item.to &&
(location.pathname === item.to ||
location.pathname.startsWith(`${item.to}/`));
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return (
<AppShell
header={{ height: 74 }}
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
navbar={{
width: sidebarCollapsed ? 72 : 264,
breakpoint: 'sm',
collapsed: { mobile: !navOpened },
}}
padding="lg"
>
{/* ---- Header ---------------------------------------------------- */}
<AppShell.Header>
<AppShell.Header
style={{
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-gray-2)',
}}
>
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<Card
p="xs"
radius="md"
withBorder
style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}
{/* Hamburger — styled like user-management Top.tsx */}
<UnstyledButton
onClick={isMobile ? toggleNav : toggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
{/* Mobile: toggle the nav drawer */}
<Burger
opened={navOpened}
onClick={toggleNav}
onClick={() => {}}
size="sm"
hiddenFrom="sm"
aria-label="Toggle navigation"
styles={{
root: { border: 'none', background: 'transparent' },
burger: { '--burger-color': 'currentColor' },
}}
/>
{/* Desktop: toggle the sidebar collapse */}
<Burger
opened={!sidebarCollapsed}
onClick={toggleSidebar}
size="sm"
visibleFrom="sm"
aria-label="Collapse sidebar"
/>
</Card>
</UnstyledButton>
{/* Breadcrumbs: Home > … */}
<Group gap={6} wrap="nowrap" visibleFrom="xs">
{/* Breadcrumbs — card container with pill-style crumbs matching DynamicBreadcrumb */}
<Group
gap={2}
wrap="nowrap"
visibleFrom="xs"
style={{
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
padding: '4px 10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
}}
>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<Group key={crumb.path} gap={6} wrap="nowrap">
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
{i > 0 && (
<Text size="sm" c="dimmed">
&gt;
</Text>
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
)}
{isLast ? (
<Text size="sm" fw={600}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: rem(999),
padding: '2px 12px',
fontSize: rem(12),
fontWeight: 600,
background: 'var(--mantine-color-primary-light)',
color: 'var(--mantine-color-primary-7)',
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
whiteSpace: 'nowrap',
lineHeight: '22px',
}}
>
{crumb.label}
</Text>
</span>
) : (
<Anchor
size="sm"
c="dimmed"
onClick={() => navigate(crumb.path)}
style={{ cursor: 'pointer' }}
c="gray.6"
onClick={() => {
navigate(crumb.path);
closeNav();
}}
style={{
cursor: 'pointer',
borderRadius: rem(999),
padding: '2px 10px',
fontSize: rem(12),
fontWeight: 500,
whiteSpace: 'nowrap',
textDecoration: 'none',
lineHeight: '22px',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
{crumb.label}
</Anchor>
)}
</Group>
</div>
);
})}
</Group>
@@ -188,24 +244,89 @@ export function PortalLayout() {
<Group gap="sm" wrap="nowrap">
<LanguageSwitcher />
<ColorSchemeToggle />
<Indicator color="red" size={9} offset={5} withBorder>
<ActionIcon variant="default" size={38} radius="md" aria-label="Notifications">
{/* Notification bell — styled like UM action buttons */}
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
aria-label="Notifications"
>
<Indicator color="red" size={9} offset={5} withBorder>
<IconBell size={19} />
</ActionIcon>
</Indicator>
</Indicator>
</UnstyledButton>
{/* Profile avatar menu */}
<Menu position="bottom-end" width={200} shadow="md" withinPortal>
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
<Menu.Target>
<ActionIcon variant="transparent" size={38} radius="xl" aria-label="Account menu">
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<BrandAvatar size={38} />
</ActionIcon>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Abebe Bekele</Menu.Label>
<Menu.Dropdown
style={{
borderRadius: rem(12),
padding: rem(6),
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
}}
>
<Menu.Label
style={{
padding: rem(10),
fontWeight: 600,
fontSize: rem(13),
}}
>
Abebe Bekele
</Menu.Label>
<Menu.Item
leftSection={<IconUserCircle size={16} />}
onClick={() => navigate('/profile')}
style={{ borderRadius: rem(8) }}
>
Profile
</Menu.Item>
@@ -214,6 +335,7 @@ export function PortalLayout() {
color="red"
leftSection={<IconLogout size={16} />}
onClick={handleLogout}
style={{ borderRadius: rem(8) }}
>
Logout
</Menu.Item>
@@ -224,31 +346,66 @@ export function PortalLayout() {
</AppShell.Header>
{/* ---- Sidebar -------------------------------------------------- */}
<AppShell.Navbar p="md" style={{ overflow: 'hidden', transition: 'width 200ms ease' }}>
<AppShell.Section pb="md">
<Group gap="sm" wrap="nowrap" justify={sidebarCollapsed ? 'center' : undefined}>
<BrandMark size={42} style={{ flexShrink: 0 }} />
{!sidebarCollapsed && (
<div>
<Text fw={700} lh={1.1}>
EMA Portal
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
Ethiopian Maritime Authority
</Text>
</div>
)}
</Group>
</AppShell.Section>
<AppShell.Navbar
p={0}
style={{
overflow: 'hidden',
transition: 'width 200ms ease',
background: 'var(--mantine-color-body)',
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
{/* Brand header — matching AppSidebar Brand section */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(12),
height: 74,
padding: sidebarCollapsed ? '0 12px' : '0 20px',
borderBottom: '1px solid var(--mantine-color-gray-2)',
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
flexShrink: 0,
}}
>
<BrandMark size={32} />
{!sidebarCollapsed && (
<div style={{ minWidth: 0 }}>
<Text
fw={800}
size="xs"
style={{
textTransform: 'uppercase',
letterSpacing: '0.08em',
lineHeight: 1.2,
color: 'var(--mantine-color-primary-7)',
}}
>
EMA Portal
</Text>
<Text
size="xs"
c="dimmed"
style={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: rem(10),
lineHeight: 1.3,
}}
>
Ethiopian Maritime Authority
</Text>
</div>
)}
</div>
<AppShell.Section grow component={ScrollArea}>
{/* Navigation items — original Mantine NavLink style */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{NAV_ITEMS.map((item) => {
const ItemIcon = item.icon;
const active =
!!item.to &&
(location.pathname === item.to ||
location.pathname.startsWith(`${item.to}/`));
const active = activeNavItem(item);
if (sidebarCollapsed) {
return (
<Tooltip key={item.label} label={item.label} position="right" withArrow>
@@ -270,6 +427,7 @@ export function PortalLayout() {
</Tooltip>
);
}
return (
<NavLink
key={item.label}
@@ -285,8 +443,14 @@ export function PortalLayout() {
</Stack>
</AppShell.Section>
{/* Collapse toggle */}
<AppShell.Section pt="xs">
{/* Collapse toggle — matching AppSidebar collapse button */}
<div
style={{
padding: rem(8),
borderTop: '1px solid var(--mantine-color-gray-2)',
flexShrink: 0,
}}
>
<Tooltip
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
position="right"
@@ -299,11 +463,26 @@ export function PortalLayout() {
style={{
display: 'flex',
alignItems: 'center',
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
gap: rem(10),
width: '100%',
padding: `${rem(10)} ${rem(12)}`,
borderRadius: rem(10),
color: 'var(--mantine-color-dimmed)',
padding: '10px 12px',
borderRadius: rem(12),
color: 'var(--mantine-color-gray-5)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
fontSize: rem(13),
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
}}
>
{sidebarCollapsed ? (
@@ -311,12 +490,12 @@ export function PortalLayout() {
) : (
<>
<IconChevronLeft size={18} stroke={1.6} />
<Text size="sm" fw={500}>Collapse</Text>
<span>Collapse</span>
</>
)}
</UnstyledButton>
</Tooltip>
</AppShell.Section>
</div>
</AppShell.Navbar>
<AppShell.Main>

View File

@@ -9,12 +9,6 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { ServicesPage } from './features/licenses/pages/ServicesPage';
import { ApplyPage } from './features/licenses/pages/ApplyPage';
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
import { MyLicensesPage } from './features/licenses/pages/MyLicensesPage';
import { LicenseDetailPage } from './features/licenses/pages/LicenseDetailPage';
import { ProfilePage } from './features/profile/pages/ProfilePage';
import { SupportPage } from './features/support/pages/SupportPage';
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
@@ -51,15 +45,9 @@ export const router = createBrowserRouter([
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/services', element: <ServicesPage /> },
{ path: '/apply', element: <ApplyPage /> },
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
{ path: '/applications', element: <ApplicationsListPage /> },
{ path: '/applications/:id', element: <ApplicationDetailPage /> },
{ path: '/licenses', element: <MyLicensesPage /> },
{ path: '/licenses/:id', element: <LicenseDetailPage /> },
{ path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },
],

View File

@@ -1,7 +1,6 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
import { licensesReducer } from '../features/licenses/store/licenses.slice';
import type { AuthUser } from '@ema-platform/auth';
configureAuthStorage('ema-portal');
@@ -19,7 +18,6 @@ export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
licenses: licensesReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,