frontend scalfolding

This commit is contained in:
Mengisteab
2026-06-10 07:03:50 +00:00
parent 4faf851a8f
commit 86edf2b971
40 changed files with 3525 additions and 79 deletions

View File

@@ -4,6 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMA Portal</title> <title>EMA Portal</title>
<script>
// Apply the saved Mantine color scheme before paint to avoid a flash.
try {
var s = localStorage.getItem('mantine-color-scheme-value') || 'light';
document.documentElement.setAttribute('data-mantine-color-scheme', s);
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,17 +1,15 @@
// src/main.tsx or App.tsx import { RouterProvider } from 'react-router-dom';
import { AppProviders, configureIam } from "@tria-plc/iamui-common"; import { configureIam } from '@tria-plc/iamui-common';
import "@tria-plc/iamui-common/styles.css"; import { AppProviders } from './providers/AppProviders';
// import { AppProviders } from './providers/AppProviders'; import { router } from './router';
import { AppRouter } from './router';
configureIam({ apiUrl: 'http://localhost:3001/api'}); // IAM module configuration (used by the isolated /users admin route).
// import './styles.css'; configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() { export function App() {
return ( return (
// 2. Wrap with Providers so hooks like useAuthUser() work
<AppProviders> <AppProviders>
<AppRouter /> <RouterProvider router={router} />
</AppProviders> </AppProviders>
); );
} }

View File

@@ -0,0 +1,21 @@
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export function ColorSchemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computed === 'dark';
return (
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('common.toggleTheme')}
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
>
{isDark ? <IconSun size={20} /> : <IconMoon size={20} />}
</ActionIcon>
);
}

View File

@@ -0,0 +1,67 @@
import { Button, Paper, Stack, Text } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface EmptyStateProps {
/** Kept for API compatibility; the illustration is used as the primary visual. */
icon?: Icon;
title: string;
description?: string;
action?: { label: string; onClick: () => void; icon?: ReactNode };
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<Paper p="xl" withBorder>
<Stack align="center" gap="sm" py="lg">
<EmptyIllustration />
<Text fw={600} fz="lg">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={440}>
{description}
</Text>
)}
{action && (
<Button mt="xs" leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Button>
)}
</Stack>
</Paper>
);
}
/** Lightweight maritime illustration: a document floating on stylized waves. */
function EmptyIllustration() {
const primary = 'var(--mantine-color-emaPrimary-6)';
const primaryLight = 'var(--mantine-color-emaPrimary-2)';
const teal = 'var(--mantine-color-emaTeal-5)';
const surface = 'var(--mantine-color-body)';
return (
<svg
width="148"
height="120"
viewBox="0 0 148 120"
fill="none"
role="img"
aria-hidden
>
<ellipse cx="74" cy="104" rx="58" ry="9" fill={primaryLight} opacity="0.45" />
{/* document */}
<g>
<rect x="46" y="20" width="56" height="68" rx="8" fill={surface} stroke={primary} strokeWidth="2.5" />
<rect x="56" y="34" width="36" height="4" rx="2" fill={primaryLight} />
<rect x="56" y="46" width="36" height="4" rx="2" fill={primaryLight} />
<rect x="56" y="58" width="24" height="4" rx="2" fill={primaryLight} />
<circle cx="92" cy="74" r="9" fill={teal} opacity="0.9" />
<path d="M88 74l3 3 5-6" stroke={surface} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</g>
{/* waves */}
<path d="M14 96c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5 8 5 16 5" stroke={teal} strokeWidth="2.5" strokeLinecap="round" opacity="0.8" />
<path d="M22 106c8 0 8-5 16-5s8 5 16 5 8-5 16-5 8 5 16 5 8-5 16-5" stroke={primary} strokeWidth="2.5" strokeLinecap="round" opacity="0.45" />
</svg>
);
}

View File

@@ -0,0 +1,52 @@
import { Menu, ActionIcon, Button } from '@mantine/core';
import { IconWorld, IconCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
interface LanguageSwitcherProps {
/** `icon` renders a compact globe button; `button` shows the language label. */
variant?: 'icon' | 'button';
}
export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
const { t, i18n } = useTranslation();
const current = i18n.language as AppLanguage;
const change = (lng: AppLanguage) => {
if (lng !== current) i18n.changeLanguage(lng);
};
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>
)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((lng) => (
<Menu.Item
key={lng}
onClick={() => change(lng)}
rightSection={
current === lng ? <IconCheck size={16} /> : undefined
}
>
{t(`language.${lng}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,25 @@
import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Stack gap={2}>
<Title order={2}>{title}</Title>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}
</Text>
)}
</Stack>
{action}
</Group>
);
}

View File

@@ -0,0 +1,96 @@
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

@@ -0,0 +1,90 @@
import { Box, Button, Group, Stack, Text, Title } from '@mantine/core';
import { IconFilePlus, IconCategory, IconAnchor } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function DashboardHero({ name }: { name?: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Box
p={{ base: 'lg', sm: 'xl' }}
style={{
position: 'relative',
overflow: 'hidden',
borderRadius: 'var(--mantine-radius-lg)',
background: 'linear-gradient(135deg, #2453a2 0%, #3160b7 45%, #1fc29d 130%)',
color: 'white',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
{/* decorative wash */}
<IconAnchor
size={220}
stroke={1}
aria-hidden
style={{
position: 'absolute',
right: -30,
bottom: -50,
opacity: 0.12,
}}
/>
<div
aria-hidden
style={{
position: 'absolute',
top: -60,
right: 120,
width: 200,
height: 200,
borderRadius: '50%',
background: 'rgba(255,255,255,0.08)',
}}
/>
<Stack gap="md" style={{ position: 'relative', maxWidth: 620 }}>
<Stack gap={4}>
<Text fz="sm" fw={600} style={{ opacity: 0.85 }}>
{t('dashboard.hero.greeting')}
{name ? ',' : ''}
</Text>
{name && (
<Title order={2} c="white">
{name}
</Title>
)}
<Text fz={{ base: 'sm', sm: 'md' }} style={{ opacity: 0.92 }} maw={520}>
{t('dashboard.hero.subtitle')}
</Text>
</Stack>
<Group gap="sm">
<Button
variant="white"
c="emaPrimary.7"
leftSection={<IconFilePlus size={18} />}
onClick={() => navigate('/apply')}
>
{t('dashboard.newApplication')}
</Button>
<Button
leftSection={<IconCategory size={18} />}
onClick={() => navigate('/services')}
styles={{
root: {
background: 'rgba(255,255,255,0.16)',
color: 'white',
border: '1px solid rgba(255,255,255,0.35)',
backdropFilter: 'blur(4px)',
},
}}
>
{t('dashboard.hero.browse')}
</Button>
</Group>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,99 @@
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,13 +1,197 @@
import { Stack, Title, Text, Paper } from '@mantine/core'; import { useEffect, useState } from 'react';
import {
Button,
Card,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
UnstyledButton,
} from '@mantine/core';
import {
IconCertificate,
IconClockHour4,
IconFilePlus,
IconAlertTriangle,
IconArrowRight,
IconRefresh,
IconSearch,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { StatCard } from '../../licenses/components/StatCard';
import { ApplicationsTable } from '../../licenses/components/ApplicationsTable';
import { useLicenses } from '../../licenses/hooks/useLicenses';
import { DashboardHero } from '../components/DashboardHero';
import { StatusDonut } from '../components/StatusDonut';
import { ApplicationsTrend } from '../components/ApplicationsTrend';
import type { ApplicationStatus } from '../../licenses/types/license.types';
const OPEN_STATUSES: ApplicationStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'INFO_REQUESTED',
'PAYMENT_PENDING',
];
export function DashboardPage() { export function DashboardPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { applications, licenses } = useLicenses();
// Brief skeleton on the charts for a polished first paint.
const [chartsLoading, setChartsLoading] = useState(true);
useEffect(() => {
const id = setTimeout(() => setChartsLoading(false), 550);
return () => clearTimeout(id);
}, []);
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length;
const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length;
const recent = applications.slice(0, 5);
const holderName = licenses[0]?.holderName;
const quickActions: {
icon: Icon;
title: string;
desc: string;
to: string;
color: string;
}[] = [
{
icon: IconFilePlus,
title: t('dashboard.quick.apply'),
desc: t('dashboard.quick.applyDesc'),
to: '/apply',
color: 'emaPrimary',
},
{
icon: IconRefresh,
title: t('dashboard.quick.renew'),
desc: t('dashboard.quick.renewDesc'),
to: '/licenses',
color: 'emaTeal',
},
{
icon: IconSearch,
title: t('dashboard.quick.track'),
desc: t('dashboard.quick.trackDesc'),
to: '/applications',
color: 'indigo',
},
];
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Title order={2}>My Dashboard</Title> <DashboardHero name={holderName} />
<Paper p="md" shadow="sm" radius="md" withBorder>
<Text c="dimmed"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
Welcome to the EMA Portal. Your content will appear here. <StatCard
</Text> label={t('dashboard.stats.activeLicenses')}
value={activeLicenses}
icon={IconCertificate}
color="teal"
/>
<StatCard
label={t('dashboard.stats.pendingApplications')}
value={pending}
icon={IconClockHour4}
color="indigo"
/>
<StatCard
label={t('dashboard.stats.expiringSoon')}
value={expiring}
icon={IconAlertTriangle}
color="orange"
/>
<StatCard
label={t('dashboard.stats.actionRequired')}
value={actionRequired}
icon={IconAlertTriangle}
color="red"
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
{quickActions.map((qa) => (
<UnstyledButton key={qa.to} onClick={() => navigate(qa.to)}>
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={qa.color} size={42}>
<qa.icon size={22} />
</ThemeIcon>
<div>
<Text fw={600}>{qa.title}</Text>
<Text size="sm" c="dimmed">
{qa.desc}
</Text>
</div>
</Group>
<IconArrowRight size={18} style={{ opacity: 0.5 }} />
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Paper p="lg" shadow="sm" withBorder>
<Group justify="space-between" mb="md">
<div>
<Title order={4}>{t('dashboard.charts.trend')}</Title>
<Text size="xs" c="dimmed">
{t('dashboard.charts.trendPeriod')}
</Text>
</div>
</Group>
{chartsLoading ? (
<Skeleton height={240} radius="md" />
) : (
<ApplicationsTrend applications={applications} />
)}
</Paper>
<Paper p="lg" shadow="sm" withBorder>
<Title order={4} mb="md">
{t('dashboard.charts.distribution')}
</Title>
{chartsLoading ? (
<Group align="center" gap="lg" wrap="nowrap">
<Skeleton circle height={180} width={180} />
<Stack gap="sm" flex={1}>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height={14} radius="sm" />
))}
</Stack>
</Group>
) : (
<StatusDonut applications={applications} />
)}
</Paper>
</SimpleGrid>
<Paper p="lg" shadow="sm" withBorder>
<Group justify="space-between" mb="sm">
<Title order={4}>{t('dashboard.recentApplications')}</Title>
<Button
variant="subtle"
size="sm"
rightSection={<IconArrowRight size={16} />}
onClick={() => navigate('/applications')}
>
{t('common.viewAll')}
</Button>
</Group>
<ApplicationsTable applications={recent} />
</Paper> </Paper>
</Stack> </Stack>
); );

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,145 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,143 @@
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

@@ -0,0 +1,83 @@
import { useMemo, useState } from 'react';
import { Button, Group, Paper, Select, Stack, TextInput } from '@mantine/core';
import { IconFilePlus, IconSearch } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
import { ApplicationsTable } from '../components/ApplicationsTable';
import { useLicenses } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
import { APPLICATION_STATUS_LABELS } from '../constants';
import type { ApplicationStatus } from '../types/license.types';
const STATUS_VALUES = Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[];
export function ApplicationsListPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { applications } = useLicenses();
const { applicationStatus } = useLicenseLabels();
const [query, setQuery] = useState('');
const [status, setStatus] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return applications.filter((app) => {
const matchesStatus = !status || app.status === status;
const matchesQuery =
!q ||
app.referenceNo.toLowerCase().includes(q) ||
app.subjectName.toLowerCase().includes(q) ||
app.applicantName.toLowerCase().includes(q);
return matchesStatus && matchesQuery;
});
}, [applications, query, status]);
const statusOptions = [
{ value: '', label: t('common.all') },
...STATUS_VALUES.map((s) => ({ value: s, label: applicationStatus(s) })),
];
return (
<Stack gap="lg">
<PageHeader
title={t('applications.title')}
subtitle={t('applications.subtitle')}
action={
<Button
leftSection={<IconFilePlus size={18} />}
onClick={() => navigate('/apply')}
>
{t('dashboard.newApplication')}
</Button>
}
/>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
flex={1}
miw={220}
leftSection={<IconSearch size={16} />}
placeholder={t('applications.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
<Select
w={220}
placeholder={t('common.status')}
data={statusOptions}
value={status ?? ''}
onChange={(v) => setStatus(v || null)}
clearable={false}
/>
</Group>
<ApplicationsTable applications={filtered} />
</Stack>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,252 @@
import { useMemo, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Stepper,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconCircleCheck, IconInfoCircle } from '@tabler/icons-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { REQUIRED_DOCUMENTS } from '../constants';
import { PageHeader } from '../../../components/PageHeader';
import { useLicenses } from '../hooks/useLicenses';
import { useLicenseLabels } from '../hooks/useLicenseLabels';
import type { LicenseType, RequestType } from '../types/license.types';
import { LICENSE_TYPE_LABELS } from '../constants';
const APPLY_REQUEST_TYPES: RequestType[] = ['NEW', 'RENEWAL'];
const LICENSE_TYPE_VALUES = Object.keys(LICENSE_TYPE_LABELS) as LicenseType[];
export function ApplyPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [params] = useSearchParams();
const { submit } = useLicenses();
const { licenseType: ltLabel, subjectLabel, requestType: rtLabel, doc } =
useLicenseLabels();
const presetType = params.get('type') as LicenseType | null;
const presetRequest = params.get('request') as RequestType | null;
const [active, setActive] = useState(0);
const [requestType, setRequestType] = useState<RequestType>(
presetRequest && APPLY_REQUEST_TYPES.includes(presetRequest) ? presetRequest : 'NEW',
);
const [licenseType, setLicenseType] = useState<LicenseType | null>(
presetType && LICENSE_TYPE_VALUES.includes(presetType) ? presetType : null,
);
const [applicantName, setApplicantName] = 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 subjectFieldLabel = licenseType
? subjectLabel(licenseType)
: t('apply.fields.applicantName');
const canContinue = () => {
if (active === 0) return !!licenseType;
if (active === 1) return !!applicantName.trim() && !!subjectName.trim();
return true;
};
const allDocsUploaded =
requiredDocs.length > 0 && requiredDocs.every((d) => uploaded[d]);
const next = () => setActive((c) => Math.min(c + 1, 3));
const prev = () => setActive((c) => Math.max(c - 1, 0));
const toggleDoc = (name: string) =>
setUploaded((prevState) => ({ ...prevState, [name]: !prevState[name] }));
const handleSubmit = () => {
if (!licenseType) return;
submit({
requestType,
licenseType,
applicantName: applicantName.trim(),
subjectName: subjectName.trim(),
notes: notes.trim() || undefined,
documents: requiredDocs.filter((d) => uploaded[d]),
});
notify.success(t('apply.submitted'));
navigate('/applications');
};
return (
<Stack gap="lg" maw={860} mx="auto">
<PageHeader title={t('apply.title')} subtitle={t('apply.subtitle')} />
<Paper p="xl" shadow="sm" radius="md" withBorder>
<Stepper active={active} onStepClick={setActive} size="sm">
<Stepper.Step
label={t('apply.steps.license')}
description={t('apply.steps.licenseDesc')}
>
<Stack gap="md" mt="xl">
<Select
label={t('apply.fields.requestType')}
data={APPLY_REQUEST_TYPES.map((v) => ({
value: v,
label: rtLabel(v),
}))}
value={requestType}
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
allowDeselect={false}
/>
<Select
label={t('apply.fields.licenseType')}
placeholder={t('apply.fields.licenseTypePlaceholder')}
data={LICENSE_TYPE_VALUES.map((v) => ({
value: v,
label: ltLabel(v),
}))}
value={licenseType}
onChange={(v) => setLicenseType(v as LicenseType)}
searchable
/>
</Stack>
</Stepper.Step>
<Stepper.Step
label={t('apply.steps.details')}
description={t('apply.steps.detailsDesc')}
>
<Stack gap="md" mt="xl">
<TextInput
label={t('apply.fields.applicantName')}
placeholder={t('apply.fields.applicantPlaceholder')}
value={applicantName}
onChange={(e) => setApplicantName(e.currentTarget.value)}
/>
<TextInput
label={subjectFieldLabel}
placeholder={subjectFieldLabel}
value={subjectName}
onChange={(e) => setSubjectName(e.currentTarget.value)}
/>
<Textarea
label={`${t('apply.fields.notes')} (${t('common.optional')})`}
placeholder={t('apply.fields.notesPlaceholder')}
minRows={3}
autosize
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
</Stack>
</Stepper.Step>
<Stepper.Step
label={t('apply.steps.documents')}
description={t('apply.steps.documentsDesc')}
>
<Stack gap="sm" mt="xl">
<Alert
variant="light"
color="emaPrimary"
icon={<IconInfoCircle size={18} />}
>
{t('apply.docs.hint')}
</Alert>
{requiredDocs.map((d) => (
<Checkbox
key={d}
label={doc(d)}
checked={!!uploaded[d]}
onChange={() => toggleDoc(d)}
/>
))}
</Stack>
</Stepper.Step>
<Stepper.Completed>
<Stack gap="md" mt="xl">
<Alert
variant="light"
color="teal"
icon={<IconCircleCheck size={18} />}
title={t('apply.review.title')}
>
{t('apply.review.hint')}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<ReviewItem
label={t('apply.fields.requestType')}
value={rtLabel(requestType)}
/>
<ReviewItem
label={t('apply.fields.licenseType')}
value={licenseType ? ltLabel(licenseType) : '—'}
/>
<ReviewItem
label={t('apply.fields.applicantName')}
value={applicantName || '—'}
/>
<ReviewItem label={subjectFieldLabel} value={subjectName || '—'} />
</SimpleGrid>
<div>
<Text size="sm" fw={600} mb={4}>
{t('apply.docs.attached')}
</Text>
<Text size="sm" c="dimmed">
{t('apply.docs.attachedCount', {
count: requiredDocs.filter((d) => uploaded[d]).length,
total: requiredDocs.length,
})}
</Text>
</div>
{!allDocsUploaded && (
<Alert variant="light" color="orange">
{t('apply.review.missingDocs')}
</Alert>
)}
</Stack>
</Stepper.Completed>
</Stepper>
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={prev} disabled={active === 0}>
{t('common.back')}
</Button>
{active < 3 ? (
<Button onClick={next} disabled={!canContinue()}>
{t('common.continue')}
</Button>
) : (
<Button color="teal" onClick={handleSubmit}>
{t('apply.submitAction')}
</Button>
)}
</Group>
</Paper>
</Stack>
);
}
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>
);
}

View File

@@ -0,0 +1,158 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,172 @@
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

@@ -0,0 +1,82 @@
// 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

@@ -0,0 +1,109 @@
import {
Avatar,
Button,
Group,
Paper,
Select,
SimpleGrid,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { PageHeader } from '../../../components/PageHeader';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
export function ProfilePage() {
const { t, i18n } = useTranslation();
const handleSave = () => notify.success(t('common.saved'));
return (
<Stack gap="lg" maw={820}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Group mb="lg">
<Avatar color="emaPrimary" radius="xl" size={64}>
BN
</Avatar>
<div>
<Title order={4}>Blue Nile Shipping PLC</Title>
<TextInput
variant="unstyled"
defaultValue="muluhabit@gmail.com"
readOnly
size="sm"
/>
</div>
</Group>
<Stack gap="lg">
<div>
<Title order={5} mb="sm">
{t('profile.personal')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.fullName')}
defaultValue="Dawit Bekele"
/>
<TextInput
label={t('profile.fields.organization')}
defaultValue="Blue Nile Shipping PLC"
/>
</SimpleGrid>
</div>
<div>
<Title order={5} mb="sm">
{t('profile.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.email')}
defaultValue="muluhabit@gmail.com"
/>
<TextInput
label={t('profile.fields.phone')}
defaultValue="+251 91 234 5678"
/>
<TextInput
label={t('profile.fields.address')}
defaultValue="Addis Ababa, Ethiopia"
/>
</SimpleGrid>
</div>
<div>
<Title order={5} mb="sm">
{t('profile.preferences')}
</Title>
<Select
maw={320}
label={t('profile.fields.language')}
data={SUPPORTED_LANGUAGES.map((lng) => ({
value: lng,
label: t(`language.${lng}`),
}))}
value={i18n.language}
onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)}
allowDeselect={false}
/>
</div>
<Group>
<Button
leftSection={<IconDeviceFloppy size={18} />}
onClick={handleSave}
>
{t('common.save')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,77 @@
import {
Accordion,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconPhone,
IconMail,
IconMapPin,
IconClock,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
const FAQ_KEYS = ['1', '2', '3', '4'] as const;
export function SupportPage() {
const { t } = useTranslation();
const contacts: { icon: Icon; label: string; value: string }[] = [
{ icon: IconPhone, label: t('support.phone'), value: '+251 11 551 7170' },
{ icon: IconMail, label: t('support.email'), value: 'info@maritime.gov.et' },
{ icon: IconMapPin, label: t('support.office'), value: t('support.officeValue') },
{ icon: IconClock, label: t('support.hours'), value: t('support.hoursValue') },
];
return (
<Stack gap="lg">
<PageHeader title={t('support.title')} subtitle={t('support.subtitle')} />
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={4} mb="md">
{t('support.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{contacts.map((c) => (
<Group key={c.label} gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="emaPrimary" size={42} radius="md">
<c.icon size={22} />
</ThemeIcon>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{c.label}
</Text>
<Text size="sm">{c.value}</Text>
</div>
</Group>
))}
</SimpleGrid>
</Paper>
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={4} mb="md">
{t('support.faq')}
</Title>
<Accordion variant="separated" radius="md">
{FAQ_KEYS.map((k) => (
<Accordion.Item key={k} value={k}>
<Accordion.Control>{t(`support.faqs.q${k}`)}</Accordion.Control>
<Accordion.Panel>
<Text size="sm" c="dimmed">
{t(`support.faqs.a${k}`)}
</Text>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { en } from './locales/en';
import { am } from './locales/am';
export const SUPPORTED_LANGUAGES = ['en', 'am'] as const;
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const STORAGE_KEY = 'ema-portal-lang';
function getInitialLanguage(): AppLanguage {
const stored =
typeof localStorage !== 'undefined'
? (localStorage.getItem(STORAGE_KEY) as AppLanguage | null)
: null;
if (stored && SUPPORTED_LANGUAGES.includes(stored)) return stored;
return 'en';
}
// IMPORTANT: use a DEDICATED instance, not the global i18next singleton.
// `@tria-plc/iamui-common` initializes the global singleton with its own
// resources and `fallbackLng: 'am'`; sharing it would clobber the portal's
// translations (keys would render literally and the language would flip to
// Amharic). An isolated instance + <I18nextProvider> keeps the portal's i18n
// independent of the IAM module.
export const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
am: { translation: am },
},
lng: getInitialLanguage(),
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
interpolation: { escapeValue: false },
returnNull: false,
});
i18n.on('languageChanged', (lng) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, lng);
}
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
export default i18n;

View File

@@ -0,0 +1,346 @@
// 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 = {
app: {
name: 'ኢባባ ፖርታል',
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
language: {
label: 'ቋንቋ',
en: 'English',
am: 'አማርኛ',
},
nav: {
dashboard: 'ዳሽቦርድ',
services: 'አገልግሎቶች',
apply: 'አዲስ ማመልከቻ',
applications: 'ማመልከቻዎቼ',
licenses: 'ፍቃዶቼ',
profile: 'መገለጫ',
support: 'እገዛና ድጋፍ',
},
common: {
back: 'ተመለስ',
continue: 'ቀጥል',
submit: 'አስገባ',
cancel: 'ሰርዝ',
save: 'ለውጦችን አስቀምጥ',
saved: 'ለውጦች ተቀምጠዋል',
viewAll: 'ሁሉንም ይመልከቱ',
viewDetails: 'ዝርዝር ይመልከቱ',
search: 'ፈልግ',
download: 'አውርድ',
print: 'አትም',
renew: 'አድስ',
apply: 'አመልክት',
applyNow: 'አሁን አመልክት',
filter: 'አጣራ',
all: 'ሁሉም',
status: 'ሁኔታ',
date: 'ቀን',
actions: 'ድርጊቶች',
loading: 'በመጫን ላይ…',
none: 'ምንም የለም',
optional: 'አማራጭ',
required: 'ግዴታ',
of: 'ከ',
backToList: 'ወደ ዝርዝሩ ተመለስ',
notFound: 'አልተገኘም',
learnMore: 'ተጨማሪ ይወቁ',
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
welcome: 'እንኳን ደህና መጡ',
},
auth: {
login: 'ግባ',
logout: 'ውጣ',
signup: 'ይመዝገቡ',
account: 'መለያ',
guest: 'እንግዳ ተጠቃሚ',
},
dashboard: {
title: 'ዳሽቦርዴ',
subtitle: 'የፍቃዶችዎ፣ የማመልከቻዎችዎና የተገዢነት ሁኔታ አጠቃላይ እይታ።',
newApplication: 'አዲስ ማመልከቻ',
recentApplications: 'የቅርብ ጊዜ ማመልከቻዎች',
applicationsByStatus: 'ማመልከቻዎች በሁኔታ',
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: {
title: 'መገለጫዬ',
subtitle: 'የመለያ ዝርዝሮችዎንና ምርጫዎችዎን ያስተዳድሩ።',
personal: 'የግል መረጃ',
contact: 'መገናኛ',
preferences: 'ምርጫዎች',
fields: {
fullName: 'ሙሉ ስም',
organization: 'ድርጅት',
email: 'የኢሜይል አድራሻ',
phone: 'ስልክ ቁጥር',
address: 'አድራሻ',
language: 'የሚመረጥ ቋንቋ',
},
},
support: {
title: 'እገዛና ድጋፍ',
subtitle: 'መመሪያ፣ የመገናኛ መንገዶችና ለተደጋጋሚ ጥያቄዎች መልሶች።',
contact: 'ባለሥልጣኑን ያግኙ',
phone: 'ስልክ',
email: 'ኢሜይል',
office: 'ዋና መሥሪያ ቤት',
officeValue: 'አዲስ አበባ፣ ኢትዮጵያ',
hours: 'የሥራ ሰዓታት',
hoursValue: 'ሰኞ–ዓርብ፣ 2:30 11:00 ሰዓት',
faq: 'ተደጋጋሚ ጥያቄዎች',
faqs: {
q1: 'የፍቃድ ማስኬጃ ምን ያህል ጊዜ ይወስዳል?',
a1: 'አብዛኞቹ ማመልከቻዎች እንደ ፍቃዱ ዓይነትና እንደ ሰነዶችዎ ሙሉነት ከ515 የሥራ ቀናት ውስጥ ይገመገማሉ።',
q2: 'ማመልከቻ ካስገባሁ በኋላ ምን ይከሰታል?',
a2: 'ማመልከቻዎ ሰነዶችዎን ለሚያረጋግጥ የፍቃድ መኮንን ይመደባል። እያንዳንዱን የሁኔታ ለውጥ ከ"ማመልከቻዎቼ" ገጽ መከታተል ይችላሉ።',
q3: 'የሚያበቃ ፍቃድ እንዴት አድሳለሁ?',
a3: 'ከ"ፍቃዶቼ" ፍቃዱን ይክፈቱና "አድስ" ይምረጡ፣ ወይም አዲስ ማመልከቻ ይጀምሩና "እድሳት" የጥያቄ ዓይነት ይምረጡ።',
q4: 'ኢባባ ተጨማሪ መረጃ ቢጠይቅስ?',
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

@@ -0,0 +1,347 @@
// 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',
authority: 'Ethiopian Maritime Authority',
tagline: 'Maritime licensing & certification services',
},
language: {
label: 'Language',
en: 'English',
am: 'አማርኛ',
},
nav: {
dashboard: 'Dashboard',
services: 'Services',
apply: 'New Application',
applications: 'My Applications',
licenses: 'My Licences',
profile: 'Profile',
support: 'Help & Support',
},
common: {
back: 'Back',
continue: 'Continue',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save changes',
saved: 'Changes saved',
viewAll: 'View all',
viewDetails: 'View details',
search: 'Search',
download: 'Download',
print: 'Print',
renew: 'Renew',
apply: 'Apply',
applyNow: 'Apply now',
filter: 'Filter',
all: 'All',
status: 'Status',
date: 'Date',
actions: 'Actions',
loading: 'Loading…',
none: 'None',
optional: 'optional',
required: 'Required',
of: 'of',
backToList: 'Back to list',
notFound: 'Not found',
learnMore: 'Learn more',
toggleTheme: 'Toggle light / dark mode',
welcome: 'Welcome back',
},
auth: {
login: 'Log in',
logout: 'Log out',
signup: 'Sign up',
account: 'Account',
guest: 'Guest user',
},
dashboard: {
title: 'My Dashboard',
subtitle: 'Overview of your licences, applications and compliance status.',
newApplication: 'New Application',
recentApplications: 'Recent Applications',
applicationsByStatus: 'Applications by status',
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: {
title: 'My Profile',
subtitle: 'Manage your account details and preferences.',
personal: 'Personal information',
contact: 'Contact',
preferences: 'Preferences',
fields: {
fullName: 'Full name',
organization: 'Organization',
email: 'Email address',
phone: 'Phone number',
address: 'Address',
language: 'Preferred language',
},
},
support: {
title: 'Help & Support',
subtitle: 'Guidance, contact channels and answers to common questions.',
contact: 'Contact the Authority',
phone: 'Phone',
email: 'Email',
office: 'Head office',
officeValue: 'Addis Ababa, Ethiopia',
hours: 'Working hours',
hoursValue: 'MonFri, 8:30 AM 5:00 PM',
faq: 'Frequently asked questions',
faqs: {
q1: 'How long does licence processing take?',
a1: 'Most applications are reviewed within 515 working days, depending on the licence type and completeness of your documents.',
q2: 'What happens after I submit an application?',
a2: 'Your application is assigned to a licensing officer who verifies your documents. You can track every status change from the My Applications page.',
q3: 'How do I renew an expiring licence?',
a3: 'Open the licence from My Licences and choose Renew, or start a new application and select the Renewal request type.',
q4: 'What if EMA requests more information?',
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,36 +1,194 @@
import { AppShell, Group, Text, Button } from '@mantine/core'; import {
import { Outlet, useNavigate } from 'react-router-dom'; AppShell,
Burger,
Group,
NavLink,
ScrollArea,
Stack,
Text,
ThemeIcon,
Menu,
Avatar,
UnstyledButton,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLayoutDashboard,
IconCategory,
IconFilePlus,
IconFileDescription,
IconCertificate,
IconUser,
IconHelpCircle,
IconLogin,
IconLogout,
IconAnchor,
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
interface NavItem {
to: string;
labelKey: string;
icon: Icon;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/services', labelKey: 'nav.services', icon: IconCategory },
{ to: '/apply', labelKey: 'nav.apply', icon: IconFilePlus },
{ to: '/applications', labelKey: 'nav.applications', icon: IconFileDescription },
{ to: '/licenses', labelKey: 'nav.licenses', icon: IconCertificate },
{ to: '/profile', labelKey: 'nav.profile', icon: IconUser },
{ to: '/support', labelKey: 'nav.support', icon: IconHelpCircle },
];
const TOKEN_KEY = 'ema-portal-auth-token';
export function PortalLayout() { export function PortalLayout() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isLoggedIn = !!localStorage.getItem('ema-portal-auth-token'); const location = useLocation();
const [opened, { toggle, close }] = useDisclosure();
const isLoggedIn = !!localStorage.getItem(TOKEN_KEY);
const go = (to: string) => {
navigate(to);
close();
};
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
navigate('/login');
};
return ( return (
<AppShell header={{ height: 56 }} padding="md"> <AppShell
header={{ height: 60 }}
navbar={{
width: 260,
breakpoint: 'sm',
collapsed: { mobile: !opened },
}}
padding="md"
>
<AppShell.Header> <AppShell.Header>
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between" wrap="nowrap">
<Text fw={700}>EMA Portal</Text> <Group gap="sm" wrap="nowrap">
{isLoggedIn ? ( <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Button <UnstyledButton onClick={() => go('/dashboard')}>
variant="subtle" <Group gap="xs" wrap="nowrap">
size="sm" <ThemeIcon
onClick={() => { size={36}
localStorage.removeItem('ema-portal-auth-token'); radius="md"
navigate('/login'); variant="gradient"
}} gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
> >
Logout <IconAnchor size={22} />
</Button> </ThemeIcon>
) : ( <div>
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}> <Text fw={700} lh={1.1}>
Login {t('app.name')}
</Button> </Text>
)} <Text size="xs" c="dimmed" lh={1.1} visibleFrom="xs">
{t('app.authority')}
</Text>
</div>
</Group>
</UnstyledButton>
</Group>
<Group gap={4} wrap="nowrap">
<ColorSchemeToggle />
<LanguageSwitcher />
<UserMenu isLoggedIn={isLoggedIn} onLogin={() => navigate('/login')} onLogout={logout} />
</Group>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Navbar p="sm">
<AppShell.Section grow component={ScrollArea}>
<Stack gap={2}>
{NAV_ITEMS.map((item) => {
const ActiveIcon = item.icon;
const active =
location.pathname === item.to ||
location.pathname.startsWith(`${item.to}/`);
return (
<NavLink
key={item.to}
active={active}
label={t(item.labelKey)}
leftSection={<ActiveIcon size={20} stroke={1.6} />}
onClick={() => go(item.to)}
variant="light"
styles={{ root: { borderRadius: rem(8) } }}
/>
);
})}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Text size="xs" c="dimmed" ta="center" py="xs">
{t('app.authority')}
</Text>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main> <AppShell.Main>
<Outlet /> <div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
); );
} }
function UserMenu({
isLoggedIn,
onLogin,
onLogout,
}: {
isLoggedIn: boolean;
onLogin: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
return (
<Menu shadow="md" width={200} position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton>
<Group gap="xs" wrap="nowrap">
<Avatar color="emaPrimary" radius="xl" size={34}>
{isLoggedIn ? 'U' : <IconUser size={18} />}
</Avatar>
<IconChevronRight size={14} style={{ opacity: 0.5 }} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{isLoggedIn ? t('auth.account') : t('auth.guest')}</Menu.Label>
{isLoggedIn ? (
<Menu.Item
leftSection={<IconLogout size={16} />}
onClick={onLogout}
>
{t('auth.logout')}
</Menu.Item>
) : (
<Menu.Item leftSection={<IconLogin size={16} />} onClick={onLogin}>
{t('auth.login')}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
}

View File

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

View File

@@ -1,56 +1,75 @@
import { createBrowserRouter, RouterProvider, Navigate, Route, Routes } from 'react-router-dom'; import { createBrowserRouter, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout'; import { PortalLayout } from './layouts/PortalLayout';
// Auth (standalone pages, no portal chrome)
import { LoginPage } from './features/auth/pages/LoginPage'; import { LoginPage } from './features/auth/pages/LoginPage';
import { SignupPage } from './features/auth/pages/SignupPage'; import { SignupPage } from './features/auth/pages/SignupPage';
import { SetPasswordPage } from './features/auth/pages/SetPasswordPage'; import { SetPasswordPage } from './features/auth/pages/SetPasswordPage';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage'; 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 { UserManagementPage, UserManagementLayout, Login} from "@tria-plc/iamui-common"; // IAM (admin user management) — kept reachable but isolated under its own
// provider so it does not depend on the portal's provider tree.
import {
AppProviders as IamProviders,
UserManagementLayout,
UserManagementPage,
} from '@tria-plc/iamui-common';
function getToken(): string | null { function IsolatedIam({ children }: { children: ReactNode }) {
return localStorage.getItem('ema-portal-auth-token'); return <IamProviders>{children}</IamProviders>;
} }
function ProtectedRoute({ children }: { children: ReactNode }) { export const router = createBrowserRouter([
if (!getToken()) return <Navigate to="/login" replace />; // Standalone auth pages
return <>{children}</>;
}
const router = createBrowserRouter([
{
element: <UserManagementLayout />,
children: [
{path: '/user', element: <UserManagementPage />}
]
},
{ path: '/login', element: <LoginPage /> }, { path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> }, { path: '/signup', element: <SignupPage /> },
{ path: '/set-password', element: <SetPasswordPage /> }, { path: '/set-password', element: <SetPasswordPage /> },
// Portal (open access for this UI template).
// Wrapped in the portal's own i18n instance so it is isolated from the
// global i18next singleton that `@tria-plc/iamui-common` initializes.
{ {
element: <PortalLayout />, element: (
<I18nextProvider i18n={i18n}>
<PortalLayout />
</I18nextProvider>
),
children: [ children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> }, { path: '/', element: <Navigate to="/dashboard" replace /> },
{ { path: '/dashboard', element: <DashboardPage /> },
path: '/dashboard', { path: '/services', element: <ServicesPage /> },
element: ( { path: '/apply', element: <ApplyPage /> },
<ProtectedRoute> { path: '/applications', element: <ApplicationsListPage /> },
<DashboardPage /> { path: '/applications/:id', element: <ApplicationDetailPage /> },
</ProtectedRoute> { path: '/licenses', element: <MyLicensesPage /> },
), { path: '/licenses/:id', element: <LicenseDetailPage /> },
}, { path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },
], ],
}, },
// IAM admin user management (isolated providers)
{
element: (
<IsolatedIam>
<UserManagementLayout />
</IsolatedIam>
),
children: [{ path: '/users', element: <UserManagementPage /> }],
},
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },
]); ]);
export function AppRouter() {
return (
<Routes>
<Route path='/login' element={<Login/>}/>
<Route element={<UserManagementLayout />}>
<Route path="/users" element={<UserManagementPage />} />
</Route>
</Routes>
);
}

View File

@@ -3,12 +3,14 @@ import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice'; import { authReducer } from '../features/auth/store/auth.slice';
import { signupReducer } from '../features/auth/store/signup.slice'; import { signupReducer } from '../features/auth/store/signup.slice';
import { setPasswordReducer } from '../features/auth/store/set-password.slice'; import { setPasswordReducer } from '../features/auth/store/set-password.slice';
import { licensesReducer } from '../features/licenses/store/licenses.slice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
auth: authReducer, auth: authReducer,
signup: signupReducer, signup: signupReducer,
setPassword: setPasswordReducer, setPassword: setPasswordReducer,
licenses: licensesReducer,
[baseApi.reducerPath]: baseApi.reducer, [baseApi.reducerPath]: baseApi.reducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>

View File

@@ -0,0 +1,60 @@
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521;
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Tinted app background that adapts to the color scheme. */
[data-mantine-color-scheme='light'] body {
background-color: var(--ema-surface-light);
}
[data-mantine-color-scheme='dark'] body {
background-color: var(--ema-surface-dark);
}
[data-mantine-color-scheme] body {
transition: background-color 200ms ease;
}
/* ---- Motion utilities ---------------------------------------------------- */
@keyframes ema-fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ema-page-enter {
animation: ema-fade-up 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Subtle lift on interactive cards. */
.ema-hover-lift {
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.ema-hover-lift:hover {
transform: translateY(-3px);
}
@media (prefers-reduced-motion: reduce) {
.ema-page-enter,
.ema-hover-lift {
animation: none;
transition: none;
}
}

View File

@@ -0,0 +1,100 @@
import {
createTheme,
rem,
type MantineColorsTuple,
} from '@mantine/core';
// ---- Coastal Modern palette ----------------------------------------------
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
// is unaffected.
const emaPrimary: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
// Teal accent — the "coastal" half of the palette.
const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
export const portalTheme = createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary,
emaTeal,
gray: emaGray,
},
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontWeight: '700',
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.25' },
h2: { fontSize: rem(25), lineHeight: '1.3' },
h3: { fontSize: rem(21), lineHeight: '1.35' },
h4: { fontSize: rem(17), lineHeight: '1.4' },
h5: { fontSize: rem(15), lineHeight: '1.45' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
components: {
Paper: {
defaultProps: { radius: 'lg' },
},
Card: {
defaultProps: { radius: 'lg' },
},
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: 'sm' },
},
ThemeIcon: {
defaultProps: { radius: 'md' },
},
NavLink: {
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
},
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});

View File

@@ -2,9 +2,10 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import "@tria-plc/iamui-common/styles.css"; import '@tria-plc/iamui-common/styles.css';
import './app/theme/portal.css';
import { configureIam } from "@tria-plc/iamui-common"; import './app/i18n/config';
import { App } from './app/app'; import { App } from './app/app';
const root = document.getElementById('root'); const root = document.getElementById('root');