mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
seafare registration update
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
|
||||
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,13 +9,37 @@ export function ColorSchemeToggle() {
|
||||
const isDark = computed === 'dark';
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
<UnstyledButton
|
||||
aria-label={t('common.toggleTheme')}
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
{isDark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Menu, ActionIcon, Button } from '@mantine/core';
|
||||
import { IconWorld, IconCheck } from '@tabler/icons-react';
|
||||
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
|
||||
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
|
||||
|
||||
@@ -19,21 +19,51 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
||||
return (
|
||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
{variant === 'icon' ? (
|
||||
<ActionIcon variant="subtle" size="lg" aria-label={t('language.label')}>
|
||||
<IconWorld size={20} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconWorld size={16} />}
|
||||
>
|
||||
{t(`language.${current}`)}
|
||||
</Button>
|
||||
)}
|
||||
<UnstyledButton
|
||||
aria-label={t('language.label')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: rem(4),
|
||||
width: variant === 'icon' ? rem(38) : 'auto',
|
||||
height: rem(38),
|
||||
padding: variant === 'icon' ? 0 : '0 12px',
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
<IconWorld size={19} />
|
||||
{variant !== 'icon' && (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(`language.${current}`)}
|
||||
</Text>
|
||||
<IconChevronDown size={14} />
|
||||
</>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Dropdown
|
||||
style={{ borderRadius: rem(12), padding: rem(6) }}
|
||||
>
|
||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||
{SUPPORTED_LANGUAGES.map((lng) => (
|
||||
<Menu.Item
|
||||
@@ -42,6 +72,7 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
||||
rightSection={
|
||||
current === lng ? <IconCheck size={16} /> : undefined
|
||||
}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
{t(`language.${lng}`)}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,33 +1,4 @@
|
||||
import { Badge, Box, useMantineTheme } from '@mantine/core';
|
||||
import { APPLICATION_STATUS_COLORS } from '../features/licenses/constants';
|
||||
import type { ApplicationStatus } from '../features/licenses/types/license.types';
|
||||
import { useLicenseLabels } from '../features/licenses/hooks/useLicenseLabels';
|
||||
|
||||
export function StatusPill({ status }: { status: ApplicationStatus }) {
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
const color = APPLICATION_STATUS_COLORS[status];
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={color}
|
||||
radius="sm"
|
||||
tt="none"
|
||||
fw={600}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
background: `var(--mantine-color-${color}-filled)`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{applicationStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
import { Box, useMantineTheme } from '@mantine/core';
|
||||
|
||||
export function BrandAvatar({
|
||||
initials = 'AB',
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, useMantineTheme, useComputedColorScheme } from '@mantine/core';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Application } from '../../licenses/types/license.types';
|
||||
|
||||
const MONTHS_BACK = 6;
|
||||
|
||||
export function ApplicationsTrend({ applications }: { applications: Application[] }) {
|
||||
const theme = useMantineTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const scheme = useComputedColorScheme('light');
|
||||
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
|
||||
|
||||
const accent = theme.colors.emaPrimary[6];
|
||||
const gridColor = scheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2];
|
||||
const axisColor = scheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6];
|
||||
|
||||
const data = useMemo(() => {
|
||||
const now = new Date();
|
||||
const buckets: { key: string; label: string; count: number }[] = [];
|
||||
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
buckets.push({
|
||||
key: `${d.getFullYear()}-${d.getMonth()}`,
|
||||
label: d.toLocaleDateString(locale, { month: 'short' }),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
const index = new Map(buckets.map((b, i) => [b.key, i]));
|
||||
applications.forEach((a) => {
|
||||
const d = new Date(a.submittedAt);
|
||||
const k = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
const i = index.get(k);
|
||||
if (i !== undefined) buckets[i].count += 1;
|
||||
});
|
||||
return buckets;
|
||||
}, [applications, locale]);
|
||||
|
||||
return (
|
||||
<Box h={240}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 10, right: 8, left: -18, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ema-trend" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: accent, strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
boxShadow: theme.shadows.md,
|
||||
background: scheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
color: scheme === 'dark' ? 'white' : 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={accent}
|
||||
strokeWidth={2.5}
|
||||
fill="url(#ema-trend)"
|
||||
dot={{ r: 3, fill: accent, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Center, Group, Stack, Text } from '@mantine/core';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import type { Application, ApplicationStatus } from '../../licenses/types/license.types';
|
||||
import { APPLICATION_STATUS_COLORS } from '../../licenses/constants';
|
||||
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Resolve a Mantine color name + shade to its hex via the theme. */
|
||||
function useColorResolver() {
|
||||
const theme = useMantineTheme();
|
||||
return (name: string, shade = 6) => theme.colors[name]?.[shade] ?? theme.colors.gray[6];
|
||||
}
|
||||
|
||||
export function StatusDonut({ applications }: { applications: Application[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
const resolve = useColorResolver();
|
||||
|
||||
const data = useMemo(() => {
|
||||
const counts = applications.reduce<Record<string, number>>((acc, a) => {
|
||||
acc[a.status] = (acc[a.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.entries(counts).map(([status, value]) => ({
|
||||
status: status as ApplicationStatus,
|
||||
value,
|
||||
color: resolve(APPLICATION_STATUS_COLORS[status as ApplicationStatus]),
|
||||
}));
|
||||
}, [applications, resolve]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Center h={220}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('dashboard.charts.noData')}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Box w={180} h={180} style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="status"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={58}
|
||||
outerRadius={84}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.status} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Text fw={800} fz={28} lh={1}>
|
||||
{applications.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.total')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" flex={1}>
|
||||
{data.map((d) => (
|
||||
<Group key={d.status} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box w={10} h={10} style={{ borderRadius: 3, background: d.color }} />
|
||||
<Text size="sm">{applicationStatus(d.status)}</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{d.value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
@@ -16,48 +12,17 @@ import {
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconAward,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconCircleCheck,
|
||||
IconFileText,
|
||||
IconLifebuoy,
|
||||
IconShip,
|
||||
IconSquareRoundedPlus,
|
||||
IconUpload,
|
||||
IconUserPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||
import { StatusDonut } from '../components/StatusDonut';
|
||||
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
||||
import { StatusPill } from '../../../components/ui';
|
||||
|
||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'INFO_REQUESTED',
|
||||
'PAYMENT_PENDING',
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useMantineTheme();
|
||||
const { applications, licenses } = useLicenses();
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
||||
const approved = applications.filter(
|
||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||
).length;
|
||||
const documents = applications.reduce((sum, a) => sum + a.documents.length, 0);
|
||||
|
||||
const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0];
|
||||
const recent = applications.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -71,24 +36,13 @@ export function DashboardPage() {
|
||||
<Stack gap="md" maw={560}>
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={26}>
|
||||
Welcome back, {firstName} 👋
|
||||
Welcome to the EMA Portal
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||
You have {pending} applications in review and {activeLicenses} active
|
||||
licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check
|
||||
your status below.
|
||||
Manage your seafarer profile, submit applications, and track your
|
||||
maritime credentials all in one place.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
w="fit-content"
|
||||
color="white"
|
||||
c="emaPrimary.7"
|
||||
radius="md"
|
||||
leftSection={<IconSquareRoundedPlus size={18} />}
|
||||
onClick={() => navigate('/apply')}
|
||||
>
|
||||
Apply for a license
|
||||
</Button>
|
||||
</Stack>
|
||||
<Center
|
||||
visibleFrom="sm"
|
||||
@@ -101,179 +55,32 @@ export function DashboardPage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* ---- Stat cards -------------------------------------------- */}
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, lg: 4 }} spacing="lg">
|
||||
<StatCard
|
||||
icon={IconAward}
|
||||
color="teal"
|
||||
value={activeLicenses}
|
||||
label="Active Licenses"
|
||||
trend="+1 this year"
|
||||
trendColor="teal"
|
||||
/>
|
||||
<StatCard
|
||||
icon={IconClockHour4}
|
||||
color="indigo"
|
||||
value={pending}
|
||||
label="Pending Applications"
|
||||
trend="In review"
|
||||
/>
|
||||
<StatCard
|
||||
icon={IconCircleCheck}
|
||||
color="teal"
|
||||
value={approved}
|
||||
label="Approved"
|
||||
trend="100% pass"
|
||||
trendColor="teal"
|
||||
/>
|
||||
<StatCard
|
||||
icon={IconFileText}
|
||||
color="emaPrimary"
|
||||
value={documents}
|
||||
label="Documents"
|
||||
trend="Up to date"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ---- Lower row --------------------------------------------- */}
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Recent Applications</Title>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={40} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{recent.map((app) => (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz="sm">
|
||||
{app.referenceNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{licenseType(app.licenseType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{formatDate(app.submittedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Application Status
|
||||
</Title>
|
||||
<StatusDonut applications={applications} />
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Quick actions
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
<QuickAction
|
||||
icon={IconSquareRoundedPlus}
|
||||
color="emaPrimary"
|
||||
label="Apply for new license"
|
||||
onClick={() => navigate('/apply')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconUpload}
|
||||
color="teal"
|
||||
label="Upload a document"
|
||||
onClick={() => notify.info('Document upload — coming soon.')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconLifebuoy}
|
||||
color="orange"
|
||||
label="Contact support"
|
||||
onClick={() => navigate('/support')}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* ---- Quick actions ----------------------------------------- */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Quick actions
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
<QuickAction
|
||||
icon={IconUserPlus}
|
||||
color="emaPrimary"
|
||||
label="Register a new seafarer"
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconLifebuoy}
|
||||
color="orange"
|
||||
label="Contact support"
|
||||
onClick={() => navigate('/support')}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: CardIcon,
|
||||
color,
|
||||
value,
|
||||
label,
|
||||
trend,
|
||||
trendColor = 'gray',
|
||||
}: {
|
||||
icon: Icon;
|
||||
color: string;
|
||||
value: number;
|
||||
label: string;
|
||||
trend: string;
|
||||
trendColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" className="ema-hover-lift">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<ThemeIcon variant="light" color={color} size={42} radius="md">
|
||||
<CardIcon size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="xs" fw={600} c={trendColor === 'gray' ? 'dimmed' : trendColor}>
|
||||
{trend}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={30} fw={700} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
icon: ActionIconCmp,
|
||||
color,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Text, Timeline } from '@mantine/core';
|
||||
import { IconCheck, IconClock } from '@tabler/icons-react';
|
||||
import type { TimelineEvent } from '../types/license.types';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationTimeline({ history }: { history: TimelineEvent[] }) {
|
||||
const { applicationStatus, formatDate } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Timeline active={history.length - 1} bulletSize={24} lineWidth={2}>
|
||||
{history.map((event, idx) => (
|
||||
<Timeline.Item
|
||||
key={`${event.status}-${idx}`}
|
||||
bullet={
|
||||
idx === history.length - 1 ? <IconClock size={14} /> : <IconCheck size={14} />
|
||||
}
|
||||
title={applicationStatus(event.status)}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{event.note}
|
||||
</Text>
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{formatDate(event.date)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
|
||||
import { IconEye } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Application } from '../types/license.types';
|
||||
import { ApplicationStatusBadge } from './StatusBadge';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
|
||||
export function ApplicationsTable({ applications }: { applications: Application[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { licenseType, requestType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!applications.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('applications.empty')}
|
||||
action={{
|
||||
label: t('dashboard.newApplication'),
|
||||
onClick: () => navigate('/apply'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('applications.columns.reference')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.licenseType')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.request')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.subject')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.submitted')}</Table.Th>
|
||||
<Table.Th>{t('applications.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{applications.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td>
|
||||
<Anchor fw={600} onClick={() => navigate(`/applications/${app.id}`)}>
|
||||
{app.referenceNo}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{licenseType(app.licenseType)}</Table.Td>
|
||||
<Table.Td>{requestType(app.requestType)}</Table.Td>
|
||||
<Table.Td>{app.subjectName}</Table.Td>
|
||||
<Table.Td>{formatDate(app.submittedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<ApplicationStatusBadge status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t('common.viewDetails')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => navigate(`/applications/${app.id}`)}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Badge, Group, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
IconFileCheck,
|
||||
IconFileUpload,
|
||||
IconFileX,
|
||||
IconFileDots,
|
||||
} from '@tabler/icons-react';
|
||||
import type { ApplicationDocument, DocumentStatus } from '../types/license.types';
|
||||
import { DOCUMENT_STATUS_COLORS } from '../constants';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
const STATUS_ICON: Record<DocumentStatus, typeof IconFileCheck> = {
|
||||
VERIFIED: IconFileCheck,
|
||||
UPLOADED: IconFileUpload,
|
||||
REJECTED: IconFileX,
|
||||
PENDING: IconFileDots,
|
||||
};
|
||||
|
||||
export function DocumentsList({ documents }: { documents: ApplicationDocument[] }) {
|
||||
const { doc, documentStatus } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{documents.map((d) => {
|
||||
const Icon = STATUS_ICON[d.status];
|
||||
const color = DOCUMENT_STATUS_COLORS[d.status];
|
||||
return (
|
||||
<Group key={d.name} justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={34} radius="md">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{doc(d.name)}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color={color} radius="sm">
|
||||
{documentStatus(d.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ActionIcon, Anchor, Table, Tooltip } from '@mantine/core';
|
||||
import { IconEye } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { License } from '../types/license.types';
|
||||
import { LicenseStatusBadge } from './StatusBadge';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function LicensesTable({ licenses }: { licenses: License[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('licenses.columns.licenseNo')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.type')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.subject')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.issued')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.expires')}</Table.Th>
|
||||
<Table.Th>{t('licenses.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{licenses.map((lic) => (
|
||||
<Table.Tr key={lic.id}>
|
||||
<Table.Td>
|
||||
<Anchor fw={600} onClick={() => navigate(`/licenses/${lic.id}`)}>
|
||||
{lic.licenseNo}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{licenseType(lic.licenseType)}</Table.Td>
|
||||
<Table.Td>{lic.subjectName}</Table.Td>
|
||||
<Table.Td>{formatDate(lic.issuedAt)}</Table.Td>
|
||||
<Table.Td>{formatDate(lic.expiresAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<LicenseStatusBadge status={lic.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t('common.viewDetails')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => navigate(`/licenses/${lic.id}`)}
|
||||
>
|
||||
<IconEye size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Badge, Button, Card, Group, List, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
IconClock,
|
||||
IconCalendarStats,
|
||||
IconArrowRight,
|
||||
IconFileText,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LicenseType } from '../types/license.types';
|
||||
import {
|
||||
LICENSE_PROCESSING_DAYS,
|
||||
LICENSE_VALIDITY_YEARS,
|
||||
REQUIRED_DOCUMENTS,
|
||||
} from '../constants';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ServiceCard({ type }: { type: LicenseType }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { licenseType, doc } = useLicenseLabels();
|
||||
|
||||
const [min, max] = LICENSE_PROCESSING_DAYS[type];
|
||||
const years = LICENSE_VALIDITY_YEARS[type];
|
||||
const docs = REQUIRED_DOCUMENTS[type];
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={650} lh={1.25}>
|
||||
{licenseType(type)}
|
||||
</Text>
|
||||
<ThemeIcon variant="light" radius="md" size={38} color="emaPrimary">
|
||||
<IconFileText size={20} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
variant="light"
|
||||
color="indigo"
|
||||
leftSection={<IconClock size={12} />}
|
||||
>
|
||||
{t('services.processingDays', { min, max })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconCalendarStats size={12} />}
|
||||
>
|
||||
{t('services.validityYears', { count: years })}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" mb={4}>
|
||||
{t('services.requiredDocuments')}
|
||||
</Text>
|
||||
<List size="sm" spacing={2} c="dimmed">
|
||||
{docs.slice(0, 3).map((d) => (
|
||||
<List.Item key={d}>{doc(d)}</List.Item>
|
||||
))}
|
||||
{docs.length > 3 && <List.Item>+{docs.length - 3}</List.Item>}
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
mt="auto"
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate(`/apply?type=${type}`)}
|
||||
>
|
||||
{t('services.apply')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Group, Paper, Text, ThemeIcon } from '@mantine/core';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: Icon;
|
||||
color?: string;
|
||||
/** Optional helper line under the value. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: IconCmp,
|
||||
color = 'emaPrimary',
|
||||
hint,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Paper
|
||||
p="lg"
|
||||
shadow="sm"
|
||||
withBorder
|
||||
className="ema-hover-lift"
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
{/* faint accent wash in the corner */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -28,
|
||||
right: -28,
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: '50%',
|
||||
background: `var(--mantine-color-${color}-light)`,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" style={{ position: 'relative' }}>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lts={0.3}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={30} lh={1.1} mt={6}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint && (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<ThemeIcon color={color} variant="light" size={46} radius="md">
|
||||
<IconCmp size={24} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { APPLICATION_STATUS_COLORS, LICENSE_STATUS_COLORS } from '../constants';
|
||||
import type { ApplicationStatus, LicenseStatus } from '../types/license.types';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationStatusBadge({ status }: { status: ApplicationStatus }) {
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
return (
|
||||
<Badge color={APPLICATION_STATUS_COLORS[status]} variant="light" radius="sm">
|
||||
{applicationStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LicenseStatusBadge({ status }: { status: LicenseStatus }) {
|
||||
const { licenseStatus } = useLicenseLabels();
|
||||
return (
|
||||
<Badge color={LICENSE_STATUS_COLORS[status]} variant="light" radius="sm">
|
||||
{licenseStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
DocumentStatus,
|
||||
LicenseStatus,
|
||||
LicenseType,
|
||||
RequestType,
|
||||
} from './types/license.types';
|
||||
|
||||
export const LICENSE_TYPE_LABELS: Record<LicenseType, string> = {
|
||||
SEAFARER_COC: 'Certificate of Competency (CoC)',
|
||||
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
|
||||
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
|
||||
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
|
||||
SHIP_RADIO: 'Ship Station Radio Licence',
|
||||
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
|
||||
SAFETY_MANAGEMENT: 'Safety Management Certificate',
|
||||
PORT_FACILITY: 'Port Facility Operation Licence',
|
||||
BOAT_OPERATOR: 'Inland Boat Operator Licence',
|
||||
};
|
||||
|
||||
/** Whether a licence type is about a person (seafarer) or an asset (vessel/facility). */
|
||||
export const LICENSE_SUBJECT_LABEL: Record<LicenseType, string> = {
|
||||
SEAFARER_COC: 'Seafarer full name',
|
||||
SEAFARER_COP: 'Seafarer full name',
|
||||
SEAMAN_BOOK: 'Seafarer full name',
|
||||
VESSEL_REGISTRATION: 'Vessel name',
|
||||
SHIP_RADIO: 'Vessel name',
|
||||
TONNAGE_CERTIFICATE: 'Vessel name',
|
||||
SAFETY_MANAGEMENT: 'Vessel name',
|
||||
PORT_FACILITY: 'Facility name',
|
||||
BOAT_OPERATOR: 'Operator full name',
|
||||
};
|
||||
|
||||
/** Documents typically required, keyed by licence type. */
|
||||
export const REQUIRED_DOCUMENTS: Record<LicenseType, string[]> = {
|
||||
SEAFARER_COC: ['Passport copy', 'Training certificate', 'Medical fitness certificate', 'Sea service record'],
|
||||
SEAFARER_COP: ['Passport copy', 'Course completion certificate', 'Medical fitness certificate'],
|
||||
SEAMAN_BOOK: ['Passport copy', 'Passport photo', 'Police clearance'],
|
||||
VESSEL_REGISTRATION: ['Bill of sale', 'Builder certificate', 'Tonnage measurement', 'Insurance certificate'],
|
||||
SHIP_RADIO: ['Vessel registration', 'Equipment specification', 'Operator certificate'],
|
||||
TONNAGE_CERTIFICATE: ['Vessel registration', 'Survey report', 'General arrangement plan'],
|
||||
SAFETY_MANAGEMENT: ['Vessel registration', 'Safety management manual', 'Audit report'],
|
||||
PORT_FACILITY: ['Business licence', 'Facility security assessment', 'Site plan'],
|
||||
BOAT_OPERATOR: ['ID copy', 'Training certificate', 'Medical fitness certificate'],
|
||||
};
|
||||
|
||||
export const REQUEST_TYPE_LABELS: Record<RequestType, string> = {
|
||||
NEW: 'New Application',
|
||||
RENEWAL: 'Renewal',
|
||||
AMENDMENT: 'Amendment',
|
||||
COMPLIANCE: 'Compliance Submission',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_LABELS: Record<ApplicationStatus, string> = {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
INFO_REQUESTED: 'Information Requested',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ISSUED: 'Issued',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_COLORS: Record<ApplicationStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'indigo',
|
||||
INFO_REQUESTED: 'orange',
|
||||
PAYMENT_PENDING: 'yellow',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ISSUED: 'green',
|
||||
};
|
||||
|
||||
export const LICENSE_STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRING_SOON: 'Expiring Soon',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
};
|
||||
|
||||
export const LICENSE_STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
ACTIVE: 'green',
|
||||
EXPIRING_SOON: 'orange',
|
||||
EXPIRED: 'red',
|
||||
SUSPENDED: 'grape',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
PENDING: 'gray',
|
||||
UPLOADED: 'blue',
|
||||
VERIFIED: 'teal',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** Service-catalog grouping for each licence type. */
|
||||
export type ServiceCategory = 'seafarer' | 'vessel' | 'facility';
|
||||
|
||||
export const LICENSE_CATEGORY: Record<LicenseType, ServiceCategory> = {
|
||||
SEAFARER_COC: 'seafarer',
|
||||
SEAFARER_COP: 'seafarer',
|
||||
SEAMAN_BOOK: 'seafarer',
|
||||
BOAT_OPERATOR: 'seafarer',
|
||||
VESSEL_REGISTRATION: 'vessel',
|
||||
SHIP_RADIO: 'vessel',
|
||||
TONNAGE_CERTIFICATE: 'vessel',
|
||||
SAFETY_MANAGEMENT: 'vessel',
|
||||
PORT_FACILITY: 'facility',
|
||||
};
|
||||
|
||||
/** Indicative processing window [min, max] in working days (template data). */
|
||||
export const LICENSE_PROCESSING_DAYS: Record<LicenseType, [number, number]> = {
|
||||
SEAFARER_COC: [10, 15],
|
||||
SEAFARER_COP: [5, 10],
|
||||
SEAMAN_BOOK: [3, 7],
|
||||
VESSEL_REGISTRATION: [10, 20],
|
||||
SHIP_RADIO: [5, 10],
|
||||
TONNAGE_CERTIFICATE: [7, 14],
|
||||
SAFETY_MANAGEMENT: [10, 15],
|
||||
PORT_FACILITY: [15, 30],
|
||||
BOAT_OPERATOR: [3, 7],
|
||||
};
|
||||
|
||||
/** Validity period in years (template data). */
|
||||
export const LICENSE_VALIDITY_YEARS: Record<LicenseType, number> = {
|
||||
SEAFARER_COC: 5,
|
||||
SEAFARER_COP: 5,
|
||||
SEAMAN_BOOK: 10,
|
||||
VESSEL_REGISTRATION: 5,
|
||||
SHIP_RADIO: 1,
|
||||
TONNAGE_CERTIFICATE: 5,
|
||||
SAFETY_MANAGEMENT: 5,
|
||||
PORT_FACILITY: 3,
|
||||
BOAT_OPERATOR: 3,
|
||||
};
|
||||
|
||||
/** Ordered pipeline used to render application progress. */
|
||||
export const APPLICATION_PIPELINE: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'PAYMENT_PENDING',
|
||||
'APPROVED',
|
||||
'ISSUED',
|
||||
];
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
DocumentStatus,
|
||||
LicenseStatus,
|
||||
LicenseType,
|
||||
RequestType,
|
||||
} from '../types/license.types';
|
||||
|
||||
/**
|
||||
* Resolves domain enum values to localized labels and formats dates in the
|
||||
* active language. Keeps component markup free of `t(\`enum.${v}\`)` noise.
|
||||
*/
|
||||
export function useLicenseLabels() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return {
|
||||
licenseType: (v: LicenseType) => t(`licenseType.${v}`),
|
||||
subjectLabel: (v: LicenseType) => t(`subjectLabel.${v}`),
|
||||
requestType: (v: RequestType) => t(`requestType.${v}`),
|
||||
applicationStatus: (v: ApplicationStatus) => t(`applicationStatus.${v}`),
|
||||
licenseStatus: (v: LicenseStatus) => t(`licenseStatus.${v}`),
|
||||
documentStatus: (v: DocumentStatus) => t(`documentStatus.${v}`),
|
||||
doc: (name: string) => t(`documents.${name}`, { defaultValue: name }),
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { submitApplication } from '../store/licenses.slice';
|
||||
import type { NewApplicationInput } from '../types/license.types';
|
||||
|
||||
export function useLicenses() {
|
||||
const dispatch = useAppDispatch();
|
||||
const applications = useAppSelector((s) => s.licenses.applications);
|
||||
const licenses = useAppSelector((s) => s.licenses.licenses);
|
||||
|
||||
return {
|
||||
applications,
|
||||
licenses,
|
||||
submit: (input: NewApplicationInput) => dispatch(submitApplication(input)),
|
||||
};
|
||||
}
|
||||
|
||||
export function useApplication(id: string | undefined) {
|
||||
return useAppSelector((s) =>
|
||||
s.licenses.applications.find((a) => a.id === id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useLicense(id: string | undefined) {
|
||||
return useAppSelector((s) => s.licenses.licenses.find((l) => l.id === id));
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { ApplicationStatusBadge } from '../components/StatusBadge';
|
||||
import { ApplicationTimeline } from '../components/ApplicationTimeline';
|
||||
import { DocumentsList } from '../components/DocumentsList';
|
||||
import { useApplication } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function ApplicationDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const app = useApplication(id);
|
||||
const { licenseType, requestType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.notFound')}
|
||||
action={{
|
||||
label: t('common.backToList'),
|
||||
onClick: () => navigate('/applications'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
w="fit-content"
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
|
||||
<PageHeader
|
||||
title={`${t('applicationDetail.title')} · ${app.referenceNo}`}
|
||||
subtitle={licenseType(app.licenseType)}
|
||||
action={<ApplicationStatusBadge status={app.status} />}
|
||||
/>
|
||||
|
||||
{app.status === 'INFO_REQUESTED' && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={t('applicationDetail.actionRequired')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{app.notes ?? t('applicationDetail.actionRequiredDesc')}</Text>
|
||||
<Button color="orange" size="xs" w="fit-content">
|
||||
{t('applicationDetail.respond')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.overview')}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Field label={t('applicationDetail.applicant')} value={app.applicantName} />
|
||||
<Field label={t('applicationDetail.subject')} value={app.subjectName} />
|
||||
<Field
|
||||
label={t('applicationDetail.requestType')}
|
||||
value={requestType(app.requestType)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.licenseType')}
|
||||
value={licenseType(app.licenseType)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.submittedOn')}
|
||||
value={formatDate(app.submittedAt)}
|
||||
/>
|
||||
<Field
|
||||
label={t('applicationDetail.lastUpdated')}
|
||||
value={formatDate(app.updatedAt)}
|
||||
/>
|
||||
{app.relatedLicenseNo && (
|
||||
<Field
|
||||
label={t('applicationDetail.relatedLicense')}
|
||||
value={app.relatedLicenseNo}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.documents')}
|
||||
</Title>
|
||||
<DocumentsList documents={app.documents} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Title order={5} mb="md">
|
||||
{t('applicationDetail.progress')}
|
||||
</Title>
|
||||
<ApplicationTimeline history={app.history} />
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAdjustmentsHorizontal,
|
||||
IconCheck,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconCircleX,
|
||||
IconDownload,
|
||||
IconMessageCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import { APPLICATION_PIPELINE } from '../constants';
|
||||
import type {
|
||||
Application,
|
||||
ApplicationStatus,
|
||||
LicenseType,
|
||||
} from '../types/license.types';
|
||||
import { StatusPill } from '../../../components/ui';
|
||||
|
||||
const SERVICE_FEE: Record<LicenseType, number> = {
|
||||
SEAFARER_COC: 1200,
|
||||
SEAFARER_COP: 900,
|
||||
SEAMAN_BOOK: 600,
|
||||
VESSEL_REGISTRATION: 3500,
|
||||
SHIP_RADIO: 900,
|
||||
TONNAGE_CERTIFICATE: 2400,
|
||||
SAFETY_MANAGEMENT: 5000,
|
||||
PORT_FACILITY: 5000,
|
||||
BOAT_OPERATOR: 700,
|
||||
};
|
||||
|
||||
const etb = (n: number) => `ETB ${n.toLocaleString()}`;
|
||||
|
||||
const PIPELINE_LABELS: Record<string, string> = {
|
||||
SUBMITTED: 'Application submitted',
|
||||
UNDER_REVIEW: 'Document verification & review',
|
||||
PAYMENT_PENDING: 'Payment & assessment',
|
||||
APPROVED: 'Approval decision',
|
||||
ISSUED: 'License issued',
|
||||
};
|
||||
|
||||
function stageIndex(status: ApplicationStatus): number {
|
||||
if (status === 'DRAFT') return 0;
|
||||
if (status === 'INFO_REQUESTED') return 1;
|
||||
if (status === 'REJECTED') return 3;
|
||||
const i = APPLICATION_PIPELINE.indexOf(status);
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
type TabKey = 'all' | 'review' | 'approved' | 'pending';
|
||||
|
||||
export function ApplicationsListPage() {
|
||||
const { applications } = useLicenses();
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
applications[0]?.id ?? null,
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: applications.length,
|
||||
review: applications.filter((a) => a.status === 'UNDER_REVIEW').length,
|
||||
approved: applications.filter(
|
||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||
).length,
|
||||
pending: applications.filter((a) =>
|
||||
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
||||
).length,
|
||||
}),
|
||||
[applications],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
switch (tab) {
|
||||
case 'review':
|
||||
return applications.filter((a) => a.status === 'UNDER_REVIEW');
|
||||
case 'approved':
|
||||
return applications.filter(
|
||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||
);
|
||||
case 'pending':
|
||||
return applications.filter((a) =>
|
||||
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
||||
);
|
||||
default:
|
||||
return applications;
|
||||
}
|
||||
}, [applications, tab]);
|
||||
|
||||
const selected =
|
||||
applications.find((a) => a.id === selectedId) ?? filtered[0] ?? applications[0];
|
||||
|
||||
const TABS: { key: TabKey; label: string; count: number }[] = [
|
||||
{ key: 'all', label: 'All', count: counts.all },
|
||||
{ key: 'review', label: 'In Review', count: counts.review },
|
||||
{ key: 'approved', label: 'Approved', count: counts.approved },
|
||||
{ key: 'pending', label: 'Pending', count: counts.pending },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
{TABS.map((tabItem) => {
|
||||
const active = tab === tabItem.key;
|
||||
return (
|
||||
<Button
|
||||
key={tabItem.key}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
variant={active ? 'filled' : 'default'}
|
||||
onClick={() => setTab(tabItem.key)}
|
||||
rightSection={
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant={active ? 'white' : 'light'}
|
||||
color={active ? 'emaPrimary' : 'gray'}
|
||||
>
|
||||
{tabItem.count}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{tabItem.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<IconAdjustmentsHorizontal size={16} />}
|
||||
>
|
||||
Filter & sort
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Grid gutter="lg" align="start">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="xs">
|
||||
<Table verticalSpacing="md" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Fee</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={36} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => {
|
||||
const isSelected = selected?.id === app.id;
|
||||
return (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
onClick={() => setSelectedId(app.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--mantine-color-emaPrimary-light)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz="sm">
|
||||
{app.referenceNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{licenseType(app.licenseType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{formatDate(app.submittedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{etb(SERVICE_FEE[app.licenseType])}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
{selected && <TimelinePanel application={selected} />}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelinePanel({ application }: { application: Application }) {
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
const current = stageIndex(application.status);
|
||||
const historyDates = new Map(
|
||||
application.history.map((h) => [h.status, h.date] as const),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start" mb="sm">
|
||||
<div>
|
||||
<Text fw={700}>{application.referenceNo}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{licenseType(application.licenseType)}
|
||||
</Text>
|
||||
</div>
|
||||
<StatusPill status={application.status} />
|
||||
</Group>
|
||||
<Divider mb="lg" />
|
||||
|
||||
<Timeline active={current} bulletSize={26} lineWidth={2} color="emaPrimary">
|
||||
{APPLICATION_PIPELINE.map((status, i) => {
|
||||
const done = i < current;
|
||||
const isCurrent = i === current;
|
||||
const date = historyDates.get(status);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={status}
|
||||
bullet={done ? <IconCheck size={14} /> : undefined}
|
||||
title={
|
||||
<Text fw={isCurrent || done ? 600 : 500} fz="sm">
|
||||
{PIPELINE_LABELS[status]}
|
||||
</Text>
|
||||
}
|
||||
lineVariant={done ? 'solid' : 'dashed'}
|
||||
>
|
||||
<Text
|
||||
fz="xs"
|
||||
c={isCurrent ? 'emaPrimary' : 'dimmed'}
|
||||
fw={isCurrent ? 600 : 400}
|
||||
>
|
||||
{date
|
||||
? formatDate(date)
|
||||
: isCurrent
|
||||
? 'In progress'
|
||||
: 'Pending'}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
|
||||
<Group
|
||||
gap="sm"
|
||||
mt="lg"
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: 'var(--mantine-color-indigo-light)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon variant="transparent" color="indigo" size="sm">
|
||||
<IconClock size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" fw={500} c="indigo">
|
||||
Last updated {formatDate(application.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="lg" p="sm">
|
||||
<Stack gap={4}>
|
||||
<ActionRow
|
||||
icon={<IconDownload size={18} />}
|
||||
label="Download submission receipt"
|
||||
onClick={() => notify.info('Receipt download — coming soon.')}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<IconMessageCircle size={18} />}
|
||||
label="Message case officer"
|
||||
onClick={() => notify.info('Messaging — coming soon.')}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<IconCircleX size={18} />}
|
||||
label="Withdraw application"
|
||||
danger
|
||||
onClick={() => notify.info('Withdrawal — coming soon.')}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick} p="xs" style={{ borderRadius: 'var(--mantine-radius-sm)' }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box c={danger ? 'red' : 'dimmed'} style={{ display: 'flex' }}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Text fz="sm" fw={500} flex={1} c={danger ? 'red' : undefined}>
|
||||
{label}
|
||||
</Text>
|
||||
<IconChevronRight size={15} style={{ opacity: 0.4 }} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconCloudUpload,
|
||||
IconDeviceMobile,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconLifebuoy,
|
||||
IconMapPin,
|
||||
IconMessageCircle,
|
||||
IconShip,
|
||||
IconStack2,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import {
|
||||
LICENSE_PROCESSING_DAYS,
|
||||
LICENSE_TYPE_LABELS,
|
||||
LICENSE_VALIDITY_YEARS,
|
||||
REQUEST_TYPE_LABELS,
|
||||
REQUIRED_DOCUMENTS,
|
||||
} from '../constants';
|
||||
import type { LicenseType, RequestType } from '../types/license.types';
|
||||
|
||||
const SERVICE_FEE: Record<LicenseType, number> = {
|
||||
SEAFARER_COC: 1200,
|
||||
SEAFARER_COP: 900,
|
||||
SEAMAN_BOOK: 600,
|
||||
VESSEL_REGISTRATION: 3500,
|
||||
SHIP_RADIO: 900,
|
||||
TONNAGE_CERTIFICATE: 2400,
|
||||
SAFETY_MANAGEMENT: 5000,
|
||||
PORT_FACILITY: 5000,
|
||||
BOAT_OPERATOR: 700,
|
||||
};
|
||||
|
||||
const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other'];
|
||||
const REGIONS = [
|
||||
'Addis Ababa',
|
||||
'Dire Dawa',
|
||||
'Amhara',
|
||||
'Oromia',
|
||||
'Tigray',
|
||||
'Afar',
|
||||
'Somali',
|
||||
'Sidama',
|
||||
'South Ethiopia',
|
||||
'Gambela',
|
||||
'Benishangul-Gumuz',
|
||||
'Harari',
|
||||
];
|
||||
|
||||
const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({
|
||||
value: v,
|
||||
label: LICENSE_TYPE_LABELS[v],
|
||||
}));
|
||||
const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({
|
||||
value: v,
|
||||
label: REQUEST_TYPE_LABELS[v],
|
||||
}));
|
||||
|
||||
export function ApplyPage() {
|
||||
const navigate = useNavigate();
|
||||
const { submit } = useLicenses();
|
||||
const { subjectLabel, doc } = useLicenseLabels();
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [licenseType, setLicenseType] = useState<LicenseType | null>('SEAFARER_COC');
|
||||
const [category, setCategory] = useState<string | null>('Deck Officer');
|
||||
const [region, setRegion] = useState<string | null>('Addis Ababa');
|
||||
const [requestType, setRequestType] = useState<RequestType>('NEW');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [idNumber, setIdNumber] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [subjectName, setSubjectName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const requiredDocs = useMemo(
|
||||
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
||||
[licenseType],
|
||||
);
|
||||
const attached = requiredDocs.filter((d) => uploaded[d]).length;
|
||||
|
||||
const next = () => setActive((c) => Math.min(c + 1, 3));
|
||||
const prev = () => setActive((c) => Math.max(c - 1, 0));
|
||||
|
||||
const canContinue = () => {
|
||||
if (active === 0) return !!licenseType && !!fullName.trim();
|
||||
if (active === 1) return !!subjectName.trim();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!licenseType) return;
|
||||
submit({
|
||||
requestType,
|
||||
licenseType,
|
||||
applicantName: fullName.trim(),
|
||||
subjectName: subjectName.trim() || fullName.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
documents: requiredDocs.filter((d) => uploaded[d]),
|
||||
});
|
||||
notify.success('Your application has been submitted to EMA.');
|
||||
navigate('/applications');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Stepper active={active} onStepClick={setActive} size="sm" iconSize={34}>
|
||||
<Stepper.Step label="Service & Applicant" description="Step 1" />
|
||||
<Stepper.Step label="License Details" description="Step 2" />
|
||||
<Stepper.Step label="Documents" description="Step 3" />
|
||||
<Stepper.Step label="Review & Submit" description="Step 4" />
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
<Grid gutter="lg" align="start">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="Service & Applicant Details"
|
||||
subtitle="Choose the license you need and tell us who the application is for."
|
||||
/>
|
||||
<Select
|
||||
label="License type"
|
||||
leftSection={<IconShip size={18} />}
|
||||
data={LICENSE_OPTIONS}
|
||||
value={licenseType}
|
||||
onChange={(v) => setLicenseType(v as LicenseType)}
|
||||
searchable
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
label="Category"
|
||||
leftSection={<IconStack2 size={18} />}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<Select
|
||||
label="Region"
|
||||
leftSection={<IconMapPin size={18} />}
|
||||
data={REGIONS}
|
||||
value={region}
|
||||
onChange={setRegion}
|
||||
searchable
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Full name (as on ID)"
|
||||
leftSection={<IconUser size={18} />}
|
||||
placeholder="Abebe Bekele Tadesse"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="National ID / Passport"
|
||||
leftSection={<IconId size={18} />}
|
||||
placeholder="ET-1234567"
|
||||
value={idNumber}
|
||||
onChange={(e) => setIdNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
placeholder="+251 911 234 567"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Dropzone />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="License Details"
|
||||
subtitle="A few more details about this specific request."
|
||||
/>
|
||||
<Select
|
||||
label="Request type"
|
||||
data={REQUEST_OPTIONS}
|
||||
value={requestType}
|
||||
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||
placeholder={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Additional notes (optional)"
|
||||
placeholder="Anything the reviewing officer should know"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="sm">
|
||||
<SectionHead
|
||||
title="Required Documents"
|
||||
subtitle="Tick each document once you have attached it."
|
||||
/>
|
||||
<Alert variant="light" color="emaPrimary" icon={<IconInfoCircle size={18} />}>
|
||||
All required documents must be provided before the review can be completed.
|
||||
</Alert>
|
||||
{requiredDocs.map((d) => (
|
||||
<Checkbox
|
||||
key={d}
|
||||
label={doc(d)}
|
||||
checked={!!uploaded[d]}
|
||||
onChange={() =>
|
||||
setUploaded((s) => ({ ...s, [d]: !s[d] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="Review & Submit"
|
||||
subtitle="Confirm the details below, then submit to EMA."
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<ReviewItem label="License type" value={licenseType ? LICENSE_TYPE_LABELS[licenseType] : '—'} />
|
||||
<ReviewItem label="Request type" value={REQUEST_TYPE_LABELS[requestType]} />
|
||||
<ReviewItem label="Full name" value={fullName || '—'} />
|
||||
<ReviewItem label="Region" value={region ?? '—'} />
|
||||
<ReviewItem label="Subject" value={subjectName || '—'} />
|
||||
<ReviewItem
|
||||
label="Documents attached"
|
||||
value={`${attached} of ${requiredDocs.length}`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{attached < requiredDocs.length && (
|
||||
<Alert variant="light" color="orange">
|
||||
Some required documents are still missing. You can still submit, but
|
||||
review may be delayed.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={prev}
|
||||
disabled={active === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{active < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
onClick={next}
|
||||
disabled={!canContinue()}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={18} />}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Application Summary
|
||||
</Title>
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
mb="md"
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: 'var(--mantine-color-emaPrimary-light)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon variant="filled" color="emaPrimary" size={40} radius="md">
|
||||
<IconShip size={21} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} fz="sm" truncate>
|
||||
{licenseType ? LICENSE_TYPE_LABELS[licenseType] : 'Select a license'}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{category ?? '—'} · {REQUEST_TYPE_LABELS[requestType]}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SummaryRow
|
||||
label="Processing time"
|
||||
value={
|
||||
licenseType
|
||||
? `${LICENSE_PROCESSING_DAYS[licenseType][0]}–${LICENSE_PROCESSING_DAYS[licenseType][1]} working days`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="License validity"
|
||||
value={
|
||||
licenseType ? `${LICENSE_VALIDITY_YEARS[licenseType]} years` : '—'
|
||||
}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Service fee"
|
||||
value={licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700}>Total payable</Text>
|
||||
<Text fw={700} fz="lg" c="emaPrimary">
|
||||
{licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper radius="lg" p="lg" bg="var(--mantine-color-emaTeal-light)">
|
||||
<Group gap="sm" mb="xs">
|
||||
<ThemeIcon variant="filled" color="emaTeal.8" size={36} radius="md">
|
||||
<IconLifebuoy size={19} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} c="emaTeal.8">
|
||||
Need help?
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" mb="md" lh={1.55}>
|
||||
Our support team can guide you through the documents required for this
|
||||
license.
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="white"
|
||||
color="emaTeal.8"
|
||||
leftSection={<IconMessageCircle size={16} />}
|
||||
onClick={() => navigate('/support')}
|
||||
>
|
||||
Contact support
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Title order={4}>{title}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm" fw={600} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dropzone() {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="sm" fw={600} c="dimmed" mb={6}>
|
||||
Supporting document
|
||||
</Text>
|
||||
<Center
|
||||
p="xl"
|
||||
style={{
|
||||
border: '1.5px dashed var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: 'var(--mantine-color-default-hover)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => notify.info('File upload — coming soon.')}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="emaPrimary" size={46} radius="xl">
|
||||
<IconCloudUpload size={23} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" fw={600}>
|
||||
Drag & drop files here, or click to browse
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
PDF, JPG or PNG — up to 10 MB
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCertificate,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { LicenseStatusBadge } from '../components/StatusBadge';
|
||||
import { useLicense } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
|
||||
export function LicenseDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const license = useLicense(id);
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
if (!license) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconCertificate}
|
||||
title={t('common.notFound')}
|
||||
action={{
|
||||
label: t('common.backToList'),
|
||||
onClick: () => navigate('/licenses'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const daysLeft = Math.ceil(
|
||||
(new Date(license.expiresAt).getTime() - Date.now()) / 86_400_000,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => navigate('/licenses')}
|
||||
w="fit-content"
|
||||
>
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
|
||||
<PageHeader
|
||||
title={license.licenseNo}
|
||||
subtitle={licenseType(license.licenseType)}
|
||||
action={<LicenseStatusBadge status={license.status} />}
|
||||
/>
|
||||
|
||||
{license.status === 'EXPIRED' && (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={18} />}>
|
||||
{t('licenses.detail.expired')}
|
||||
</Alert>
|
||||
)}
|
||||
{license.status === 'EXPIRING_SOON' && (
|
||||
<Alert color="orange" variant="light" icon={<IconAlertTriangle size={18} />}>
|
||||
{t('licenses.detail.expiringSoon')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'emaPrimary.7', to: 'emaPrimary.5', deg: 135 }}
|
||||
>
|
||||
<IconCertificate size={30} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">
|
||||
{licenseType(license.licenseType)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{license.status !== 'EXPIRED' && daysLeft > 0 && (
|
||||
<Text c="dimmed" size="sm" ta="right">
|
||||
{t('licenses.detail.daysLeft', { count: daysLeft })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Field label={t('licenses.detail.holder')} value={license.holderName} />
|
||||
<Field label={t('licenses.detail.subject')} value={license.subjectName} />
|
||||
<Field
|
||||
label={t('licenses.detail.issuedOn')}
|
||||
value={formatDate(license.issuedAt)}
|
||||
/>
|
||||
<Field
|
||||
label={t('licenses.detail.expiresOn')}
|
||||
value={formatDate(license.expiresAt)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconDownload size={18} />}
|
||||
onClick={() => notify.info(t('common.download'))}
|
||||
>
|
||||
{t('licenses.detail.download')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={18} />}
|
||||
onClick={() => navigate(`/apply?type=${license.licenseType}&request=RENEWAL`)}
|
||||
>
|
||||
{t('licenses.detail.renew')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Paper, Stack } from '@mantine/core';
|
||||
import { IconCertificate } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { EmptyState } from '../../../components/EmptyState';
|
||||
import { LicensesTable } from '../components/LicensesTable';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
|
||||
export function MyLicensesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { licenses } = useLicenses();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader title={t('licenses.title')} subtitle={t('licenses.subtitle')} />
|
||||
|
||||
{licenses.length ? (
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<LicensesTable licenses={licenses} />
|
||||
</Paper>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconCertificate}
|
||||
title={t('licenses.empty')}
|
||||
action={{
|
||||
label: t('dashboard.newApplication'),
|
||||
onClick: () => navigate('/apply'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { SimpleGrid, Stack, Title } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { ServiceCard } from '../components/ServiceCard';
|
||||
import {
|
||||
LICENSE_CATEGORY,
|
||||
type ServiceCategory,
|
||||
} from '../constants';
|
||||
import type { LicenseType } from '../types/license.types';
|
||||
|
||||
const CATEGORY_ORDER: ServiceCategory[] = ['seafarer', 'vessel', 'facility'];
|
||||
|
||||
export function ServicesPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const grouped = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
types: (Object.keys(LICENSE_CATEGORY) as LicenseType[]).filter(
|
||||
(type) => LICENSE_CATEGORY[type] === category,
|
||||
),
|
||||
})).filter((g) => g.types.length > 0);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<PageHeader title={t('services.title')} subtitle={t('services.subtitle')} />
|
||||
|
||||
{grouped.map(({ category, types }) => (
|
||||
<Stack key={category} gap="sm">
|
||||
<Title order={4}>{t(`services.categoryLabel.${category}`)}</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{types.map((type) => (
|
||||
<ServiceCard key={type} type={type} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { REQUIRED_DOCUMENTS } from '../constants';
|
||||
import type {
|
||||
Application,
|
||||
License,
|
||||
NewApplicationInput,
|
||||
} from '../types/license.types';
|
||||
|
||||
interface LicensesState {
|
||||
applications: Application[];
|
||||
licenses: License[];
|
||||
}
|
||||
|
||||
const daysFromNow = (days: number): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
const seedApplications: Application[] = [
|
||||
{
|
||||
id: 'app-1001',
|
||||
referenceNo: 'EMA-2026-001042',
|
||||
requestType: 'NEW',
|
||||
licenseType: 'SEAFARER_COC',
|
||||
applicantName: 'Dawit Bekele',
|
||||
subjectName: 'Dawit Bekele',
|
||||
status: 'UNDER_REVIEW',
|
||||
submittedAt: daysFromNow(-9),
|
||||
updatedAt: daysFromNow(-2),
|
||||
documents: [
|
||||
{ name: 'Passport copy', status: 'VERIFIED' },
|
||||
{ name: 'Training certificate', status: 'VERIFIED' },
|
||||
{ name: 'Medical fitness certificate', status: 'UPLOADED' },
|
||||
{ name: 'Sea service record', status: 'UPLOADED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-9), note: 'Application received by EMA.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-2), note: 'Assigned to licensing officer for review.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-1002',
|
||||
referenceNo: 'EMA-2026-000987',
|
||||
requestType: 'NEW',
|
||||
licenseType: 'VESSEL_REGISTRATION',
|
||||
applicantName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'INFO_REQUESTED',
|
||||
submittedAt: daysFromNow(-15),
|
||||
updatedAt: daysFromNow(-4),
|
||||
notes: 'Tonnage measurement document is illegible — please re-upload a clear scan.',
|
||||
documents: [
|
||||
{ name: 'Bill of sale', status: 'VERIFIED' },
|
||||
{ name: 'Builder certificate', status: 'VERIFIED' },
|
||||
{ name: 'Tonnage measurement', status: 'REJECTED' },
|
||||
{ name: 'Insurance certificate', status: 'UPLOADED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-15), note: 'Application received by EMA.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-10), note: 'Document verification in progress.' },
|
||||
{ status: 'INFO_REQUESTED', date: daysFromNow(-4), note: 'Additional information requested from applicant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-1003',
|
||||
referenceNo: 'EMA-2026-000654',
|
||||
requestType: 'RENEWAL',
|
||||
licenseType: 'SHIP_RADIO',
|
||||
applicantName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'ISSUED',
|
||||
submittedAt: daysFromNow(-40),
|
||||
updatedAt: daysFromNow(-25),
|
||||
relatedLicenseNo: 'EMA-SR-2021-0231',
|
||||
documents: [
|
||||
{ name: 'Vessel registration', status: 'VERIFIED' },
|
||||
{ name: 'Equipment specification', status: 'VERIFIED' },
|
||||
{ name: 'Operator certificate', status: 'VERIFIED' },
|
||||
],
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: daysFromNow(-40), note: 'Renewal application received.' },
|
||||
{ status: 'UNDER_REVIEW', date: daysFromNow(-35), note: 'Under review.' },
|
||||
{ status: 'PAYMENT_PENDING', date: daysFromNow(-30), note: 'Service fee invoice issued.' },
|
||||
{ status: 'APPROVED', date: daysFromNow(-27), note: 'Application approved.' },
|
||||
{ status: 'ISSUED', date: daysFromNow(-25), note: 'Licence issued and available for download.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const seedLicenses: License[] = [
|
||||
{
|
||||
id: 'lic-1',
|
||||
licenseNo: 'EMA-SR-2021-0231',
|
||||
licenseType: 'SHIP_RADIO',
|
||||
holderName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Abay Star',
|
||||
status: 'ACTIVE',
|
||||
issuedAt: daysFromNow(-25),
|
||||
expiresAt: daysFromNow(340),
|
||||
},
|
||||
{
|
||||
id: 'lic-2',
|
||||
licenseNo: 'EMA-CC-2019-0098',
|
||||
licenseType: 'SEAFARER_COC',
|
||||
holderName: 'Dawit Bekele',
|
||||
subjectName: 'Dawit Bekele',
|
||||
status: 'EXPIRING_SOON',
|
||||
issuedAt: daysFromNow(-1800),
|
||||
expiresAt: daysFromNow(48),
|
||||
},
|
||||
{
|
||||
id: 'lic-3',
|
||||
licenseNo: 'EMA-VR-2018-0455',
|
||||
licenseType: 'VESSEL_REGISTRATION',
|
||||
holderName: 'Blue Nile Shipping PLC',
|
||||
subjectName: 'MV Tana',
|
||||
status: 'EXPIRED',
|
||||
issuedAt: daysFromNow(-2600),
|
||||
expiresAt: daysFromNow(-60),
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: LicensesState = {
|
||||
applications: seedApplications,
|
||||
licenses: seedLicenses,
|
||||
};
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const licensesSlice = createSlice({
|
||||
name: 'licenses',
|
||||
initialState,
|
||||
reducers: {
|
||||
submitApplication: {
|
||||
reducer(state, action: PayloadAction<Application>) {
|
||||
state.applications.unshift(action.payload);
|
||||
},
|
||||
prepare(input: NewApplicationInput) {
|
||||
const now = new Date().toISOString();
|
||||
const seq = Math.floor(100000 + Math.random() * 899999);
|
||||
const referenceNo = `EMA-${year}-${seq}`;
|
||||
const documents = (input.documents.length
|
||||
? input.documents
|
||||
: REQUIRED_DOCUMENTS[input.licenseType]
|
||||
).map((name) => ({ name, status: 'UPLOADED' as const }));
|
||||
|
||||
const application: Application = {
|
||||
id: `app-${nanoid(8)}`,
|
||||
referenceNo,
|
||||
requestType: input.requestType,
|
||||
licenseType: input.licenseType,
|
||||
applicantName: input.applicantName,
|
||||
subjectName: input.subjectName,
|
||||
relatedLicenseNo: input.relatedLicenseNo,
|
||||
notes: input.notes,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
updatedAt: now,
|
||||
documents,
|
||||
history: [
|
||||
{ status: 'SUBMITTED', date: now, note: 'Application received by EMA.' },
|
||||
],
|
||||
};
|
||||
return { payload: application };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { submitApplication } = licensesSlice.actions;
|
||||
export const licensesReducer = licensesSlice.reducer;
|
||||
@@ -1,82 +0,0 @@
|
||||
// Domain model for the Ethiopian Maritime Authority (EMA) licensing portal.
|
||||
|
||||
/** Categories of licence / certificate the Authority issues. */
|
||||
export type LicenseType =
|
||||
| 'SEAFARER_COC' // Certificate of Competency
|
||||
| 'SEAFARER_COP' // Certificate of Proficiency
|
||||
| 'SEAMAN_BOOK' // Seafarer's Identity & Record Book
|
||||
| 'VESSEL_REGISTRATION' // Vessel / Ship Registration Certificate
|
||||
| 'SHIP_RADIO' // Ship Station Radio Licence
|
||||
| 'TONNAGE_CERTIFICATE' // International Tonnage Certificate
|
||||
| 'SAFETY_MANAGEMENT' // Safety Management Certificate
|
||||
| 'PORT_FACILITY' // Port Facility Operation Licence
|
||||
| 'BOAT_OPERATOR'; // Inland Boat Operator Licence
|
||||
|
||||
/** What the applicant is asking the Authority to do. */
|
||||
export type RequestType = 'NEW' | 'RENEWAL' | 'AMENDMENT' | 'COMPLIANCE';
|
||||
|
||||
/** Lifecycle of a submitted application. */
|
||||
export type ApplicationStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'INFO_REQUESTED'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'ISSUED';
|
||||
|
||||
/** Health of an already-issued licence. */
|
||||
export type LicenseStatus = 'ACTIVE' | 'EXPIRING_SOON' | 'EXPIRED' | 'SUSPENDED';
|
||||
|
||||
export type DocumentStatus = 'PENDING' | 'UPLOADED' | 'VERIFIED' | 'REJECTED';
|
||||
|
||||
export interface ApplicationDocument {
|
||||
name: string;
|
||||
status: DocumentStatus;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
status: ApplicationStatus;
|
||||
date: string; // ISO date
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
id: string;
|
||||
referenceNo: string;
|
||||
requestType: RequestType;
|
||||
licenseType: LicenseType;
|
||||
applicantName: string;
|
||||
/** Vessel name or seafarer subject, depending on licence type. */
|
||||
subjectName: string;
|
||||
status: ApplicationStatus;
|
||||
submittedAt: string; // ISO date
|
||||
updatedAt: string; // ISO date
|
||||
/** Set once an amendment / compliance request targets an existing licence. */
|
||||
relatedLicenseNo?: string;
|
||||
notes?: string;
|
||||
documents: ApplicationDocument[];
|
||||
history: TimelineEvent[];
|
||||
}
|
||||
|
||||
export interface License {
|
||||
id: string;
|
||||
licenseNo: string;
|
||||
licenseType: LicenseType;
|
||||
holderName: string;
|
||||
subjectName: string;
|
||||
status: LicenseStatus;
|
||||
issuedAt: string; // ISO date
|
||||
expiresAt: string; // ISO date
|
||||
}
|
||||
|
||||
export interface NewApplicationInput {
|
||||
requestType: RequestType;
|
||||
licenseType: LicenseType;
|
||||
applicantName: string;
|
||||
subjectName: string;
|
||||
relatedLicenseNo?: string;
|
||||
notes?: string;
|
||||
documents: string[];
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
// Amharic (አማርኛ) translations for the EMA portal.
|
||||
// Mirrors the key structure of en.ts. Maritime/government terminology follows
|
||||
// common usage of the Ethiopian Maritime Authority.
|
||||
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const am: Translations = {
|
||||
@@ -19,10 +15,7 @@ export const am: Translations = {
|
||||
|
||||
nav: {
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
services: 'አገልግሎቶች',
|
||||
apply: 'አዲስ ማመልከቻ',
|
||||
applications: 'ማመልከቻዎቼ',
|
||||
licenses: 'ፍቃዶቼ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
profile: 'መገለጫ',
|
||||
support: 'እገዛና ድጋፍ',
|
||||
},
|
||||
@@ -39,9 +32,6 @@ export const am: Translations = {
|
||||
search: 'ፈልግ',
|
||||
download: 'አውርድ',
|
||||
print: 'አትም',
|
||||
renew: 'አድስ',
|
||||
apply: 'አመልክት',
|
||||
applyNow: 'አሁን አመልክት',
|
||||
filter: 'አጣራ',
|
||||
all: 'ሁሉም',
|
||||
status: 'ሁኔታ',
|
||||
@@ -68,159 +58,8 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
title: 'ዳሽቦርዴ',
|
||||
subtitle: 'የፍቃዶችዎ፣ የማመልከቻዎችዎና የተገዢነት ሁኔታ አጠቃላይ እይታ።',
|
||||
newApplication: 'አዲስ ማመልከቻ',
|
||||
recentApplications: 'የቅርብ ጊዜ ማመልከቻዎች',
|
||||
applicationsByStatus: 'ማመልከቻዎች በሁኔታ',
|
||||
title: 'ዳሽቦርድ',
|
||||
quickActions: 'ፈጣን ድርጊቶች',
|
||||
stats: {
|
||||
activeLicenses: 'ንቁ ፍቃዶች',
|
||||
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
|
||||
expiringSoon: 'በቅርቡ የሚያበቁ',
|
||||
actionRequired: 'እርምጃ የሚፈልጉ',
|
||||
},
|
||||
quick: {
|
||||
apply: 'ለፍቃድ ያመልክቱ',
|
||||
applyDesc: 'አዲስ ማመልከቻ ይጀምሩ',
|
||||
renew: 'ፍቃድ ያድሱ',
|
||||
renewDesc: 'የምስክር ወረቀቶችዎን የጸኑ ያድርጉ',
|
||||
track: 'ማመልከቻ ይከታተሉ',
|
||||
trackDesc: 'ጥያቄዎ የት እንዳለ ይመልከቱ',
|
||||
},
|
||||
hero: {
|
||||
greeting: 'እንኳን ደህና መጡ',
|
||||
subtitle:
|
||||
'ለባሕር ፍቃዶች ያመልክቱ፣ ማመልከቻዎችዎን ይከታተሉ እና የምስክር ወረቀቶችዎን ያስተዳድሩ — ሁሉም በአንድ ቦታ።',
|
||||
browse: 'አገልግሎቶችን ይመልከቱ',
|
||||
},
|
||||
charts: {
|
||||
trend: 'ማመልከቻዎች በጊዜ ሂደት',
|
||||
trendPeriod: 'ያለፉት 6 ወራት',
|
||||
distribution: 'የሁኔታ ስርጭት',
|
||||
noData: 'እስካሁን የሚታይ መረጃ የለም',
|
||||
total: 'ጠቅላላ',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'የባሕር አገልግሎቶች',
|
||||
subtitle:
|
||||
'በየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን የሚሰጡ ፍቃዶችንና የምስክር ወረቀቶችን ይመልከቱና ማመልከቻ ይጀምሩ።',
|
||||
seafarer: 'የመርከበኛ ምስክርነት',
|
||||
vessel: 'የመርከብ አገልግሎቶች',
|
||||
facility: 'ወደብና ተቋም',
|
||||
requiredDocuments: 'የሚያስፈልጉ ሰነዶች',
|
||||
processingTime: 'መደበኛ የማስኬጃ ጊዜ',
|
||||
validity: 'የጸናበት ጊዜ',
|
||||
apply: 'አመልክት',
|
||||
processingDays: '{{min}}–{{max}} የሥራ ቀናት',
|
||||
validityYears_one: '{{count}} ዓመት',
|
||||
validityYears_other: '{{count}} ዓመታት',
|
||||
categoryLabel: {
|
||||
seafarer: 'የመርከበኛ ምስክርነት',
|
||||
vessel: 'የመርከብ አገልግሎቶች',
|
||||
facility: 'ወደብና ተቋም',
|
||||
},
|
||||
},
|
||||
|
||||
apply: {
|
||||
title: 'ለፍቃድ ማመልከት',
|
||||
subtitle:
|
||||
'ማመልከቻዎን ለየኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ለማስገባት ከታች ያሉትን ደረጃዎች ይሙሉ።',
|
||||
steps: {
|
||||
license: 'ፍቃድ',
|
||||
licenseDesc: 'ዓይነትና ጥያቄ',
|
||||
details: 'ዝርዝሮች',
|
||||
detailsDesc: 'የአመልካች መረጃ',
|
||||
documents: 'ሰነዶች',
|
||||
documentsDesc: 'የሚያስፈልጉ ማስገቢያዎች',
|
||||
review: 'ግምገማ',
|
||||
},
|
||||
fields: {
|
||||
requestType: 'የጥያቄ ዓይነት',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
licenseTypePlaceholder: 'የሚፈልጉትን ፍቃድ ይምረጡ',
|
||||
applicantName: 'የአመልካች / ድርጅት ስም',
|
||||
applicantPlaceholder: 'ለምሳሌ ብሉ ናይል ሺፒንግ ኃ.የተ.የግ.ማ.',
|
||||
notes: 'ተጨማሪ ማስታወሻ',
|
||||
notesPlaceholder: 'ገምጋሚው መኮንን ሊያውቀው የሚገባ ማንኛውም ነገር',
|
||||
relatedLicense: 'ነባር የፍቃድ ቁጥር',
|
||||
},
|
||||
docs: {
|
||||
hint: 'እያንዳንዱን ሰነድ ካያያዙ በኋላ ምልክት ያድርጉ። ከማስገባትዎ በፊት ሁሉም የሚያስፈልጉ ሰነዶች መቅረብ አለባቸው።',
|
||||
attached: 'የተያያዙ ሰነዶች',
|
||||
attachedCount: '{{count}} ከ{{total}} የሚያስፈልጉ ሰነዶች',
|
||||
},
|
||||
review: {
|
||||
title: 'ማመልከቻዎን ይገምግሙ',
|
||||
hint: 'ከታች ያሉትን ዝርዝሮች ያረጋግጡና ለኢባባ ያስገቡ።',
|
||||
missingDocs:
|
||||
'አንዳንድ የሚያስፈልጉ ሰነዶች አሁንም ይጎድላሉ። አሁንም ማስገባት ይችላሉ፣ ነገር ግን ግምገማው ሊዘገይ ይችላል።',
|
||||
},
|
||||
submitted: 'ማመልከቻዎ ለኢባባ ቀርቧል።',
|
||||
submitAction: 'ማመልከቻ አስገባ',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'ማመልከቻዎቼ',
|
||||
subtitle: 'ለኢባባ ያስገቡትን እያንዳንዱን ጥያቄ ሁኔታ ይከታተሉ።',
|
||||
empty: 'እስካሁን ምንም ማመልከቻ የለም። ለፍቃድ በማመልከት ይጀምሩ።',
|
||||
searchPlaceholder: 'በማመሳከሪያ፣ በርዕሰ ጉዳይ ወይም በአመልካች ይፈልጉ',
|
||||
columns: {
|
||||
reference: 'የማመሳከሪያ ቁ.',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
request: 'ጥያቄ',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
submitted: 'የቀረበበት',
|
||||
status: 'ሁኔታ',
|
||||
},
|
||||
},
|
||||
|
||||
applicationDetail: {
|
||||
title: 'ማመልከቻ',
|
||||
reference: 'የማመሳከሪያ ቁጥር',
|
||||
overview: 'አጠቃላይ እይታ',
|
||||
progress: 'ሂደት',
|
||||
documents: 'ሰነዶች',
|
||||
history: 'ታሪክ',
|
||||
applicant: 'አመልካች',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
requestType: 'የጥያቄ ዓይነት',
|
||||
licenseType: 'የፍቃድ ዓይነት',
|
||||
submittedOn: 'የቀረበበት ቀን',
|
||||
lastUpdated: 'መጨረሻ የተሻሻለበት',
|
||||
officerNote: 'ከኢባባ የተሰጠ ማስታወሻ',
|
||||
relatedLicense: 'ተዛማጅ ፍቃድ',
|
||||
actionRequired: 'እርምጃ ያስፈልጋል',
|
||||
actionRequiredDesc:
|
||||
'ማመልከቻዎ ከመቀጠሉ በፊት ኢባባ ተጨማሪ መረጃ ጠይቋል።',
|
||||
respond: 'ለጥያቄው ምላሽ ይስጡ',
|
||||
},
|
||||
|
||||
licenses: {
|
||||
title: 'ፍቃዶቼ',
|
||||
subtitle: 'በኢባባ ለእርስዎ የተሰጡ ሁሉም የምስክር ወረቀቶችና ፍቃዶች።',
|
||||
empty: 'እስካሁን የተሰጠዎት ፍቃድ የለም።',
|
||||
columns: {
|
||||
licenseNo: 'የፍቃድ ቁ.',
|
||||
type: 'ዓይነት',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
issued: 'የተሰጠበት',
|
||||
expires: 'የሚያበቃበት',
|
||||
status: 'ሁኔታ',
|
||||
},
|
||||
detail: {
|
||||
holder: 'የፍቃድ ባለቤት',
|
||||
subject: 'ርዕሰ ጉዳይ',
|
||||
issuedOn: 'የተሰጠበት ቀን',
|
||||
expiresOn: 'የሚያበቃበት ቀን',
|
||||
daysLeft: '{{count}} ቀናት ይቀራሉ',
|
||||
expired: 'ይህ ፍቃድ ጊዜው አልፎበታል።',
|
||||
expiringSoon: 'ይህ ፍቃድ በቅርቡ ያበቃል። ተገዢ ለመሆን ያድሱ።',
|
||||
download: 'የምስክር ወረቀት አውርድ',
|
||||
renew: 'ፍቃድ አድስ',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
@@ -319,86 +158,4 @@ export const am: Translations = {
|
||||
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Domain enums ----
|
||||
licenseType: {
|
||||
SEAFARER_COC: 'የብቃት ምስክር ወረቀት (CoC)',
|
||||
SEAFARER_COP: 'የክህሎት ምስክር ወረቀት (CoP)',
|
||||
SEAMAN_BOOK: 'የመርከበኛ መታወቂያና መዝገብ ደብተር',
|
||||
VESSEL_REGISTRATION: 'የመርከብ ምዝገባ ምስክር ወረቀት',
|
||||
SHIP_RADIO: 'የመርከብ ሬዲዮ ጣቢያ ፍቃድ',
|
||||
TONNAGE_CERTIFICATE: 'ዓለም አቀፍ የመርከብ ጭነት መለኪያ ምስክር ወረቀት',
|
||||
SAFETY_MANAGEMENT: 'የደህንነት አስተዳደር ምስክር ወረቀት',
|
||||
PORT_FACILITY: 'የወደብ ተቋም ማንቀሳቀሻ ፍቃድ',
|
||||
BOAT_OPERATOR: 'የውስጥ ውሃ ጀልባ አንቀሳቃሽ ፍቃድ',
|
||||
},
|
||||
|
||||
subjectLabel: {
|
||||
SEAFARER_COC: 'የመርከበኛ ሙሉ ስም',
|
||||
SEAFARER_COP: 'የመርከበኛ ሙሉ ስም',
|
||||
SEAMAN_BOOK: 'የመርከበኛ ሙሉ ስም',
|
||||
VESSEL_REGISTRATION: 'የመርከብ ስም',
|
||||
SHIP_RADIO: 'የመርከብ ስም',
|
||||
TONNAGE_CERTIFICATE: 'የመርከብ ስም',
|
||||
SAFETY_MANAGEMENT: 'የመርከብ ስም',
|
||||
PORT_FACILITY: 'የተቋም ስም',
|
||||
BOAT_OPERATOR: 'የአንቀሳቃሽ ሙሉ ስም',
|
||||
},
|
||||
|
||||
requestType: {
|
||||
NEW: 'አዲስ ማመልከቻ',
|
||||
RENEWAL: 'እድሳት',
|
||||
AMENDMENT: 'ማሻሻያ',
|
||||
COMPLIANCE: 'የተገዢነት ማቅረቢያ',
|
||||
},
|
||||
|
||||
applicationStatus: {
|
||||
DRAFT: 'ረቂቅ',
|
||||
SUBMITTED: 'ቀርቧል',
|
||||
UNDER_REVIEW: 'በግምገማ ላይ',
|
||||
INFO_REQUESTED: 'መረጃ ተጠይቋል',
|
||||
PAYMENT_PENDING: 'ክፍያ በመጠባበቅ ላይ',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
ISSUED: 'ተሰጥቷል',
|
||||
},
|
||||
|
||||
licenseStatus: {
|
||||
ACTIVE: 'ንቁ',
|
||||
EXPIRING_SOON: 'በቅርቡ የሚያበቃ',
|
||||
EXPIRED: 'ጊዜው ያለፈበት',
|
||||
SUSPENDED: 'ታግዷል',
|
||||
},
|
||||
|
||||
documentStatus: {
|
||||
PENDING: 'በመጠባበቅ ላይ',
|
||||
UPLOADED: 'ተጭኗል',
|
||||
VERIFIED: 'ተረጋግጧል',
|
||||
REJECTED: 'ተቀባይነት አላገኘም',
|
||||
},
|
||||
|
||||
documents: {
|
||||
'Passport copy': 'የፓስፖርት ቅጂ',
|
||||
'Passport photo': 'የፓስፖርት ፎቶ',
|
||||
'Training certificate': 'የሥልጠና ምስክር ወረቀት',
|
||||
'Course completion certificate': 'የኮርስ ማጠናቀቂያ ምስክር ወረቀት',
|
||||
'Medical fitness certificate': 'የጤና ብቁነት ምስክር ወረቀት',
|
||||
'Sea service record': 'የባሕር አገልግሎት መዝገብ',
|
||||
'Police clearance': 'የፖሊስ ማረጋገጫ',
|
||||
'ID copy': 'የመታወቂያ ቅጂ',
|
||||
'Bill of sale': 'የሽያጭ ሰነድ',
|
||||
'Builder certificate': 'የገንቢ ምስክር ወረቀት',
|
||||
'Tonnage measurement': 'የጭነት መለኪያ',
|
||||
'Insurance certificate': 'የመድን ምስክር ወረቀት',
|
||||
'Vessel registration': 'የመርከብ ምዝገባ',
|
||||
'Equipment specification': 'የመሣሪያ ዝርዝር',
|
||||
'Operator certificate': 'የአንቀሳቃሽ ምስክር ወረቀት',
|
||||
'Survey report': 'የዳሰሳ ሪፖርት',
|
||||
'General arrangement plan': 'አጠቃላይ የአደረጃጀት ዕቅድ',
|
||||
'Safety management manual': 'የደህንነት አስተዳደር መመሪያ',
|
||||
'Audit report': 'የኦዲት ሪፖርት',
|
||||
'Business licence': 'የንግድ ፍቃድ',
|
||||
'Facility security assessment': 'የተቋም ደህንነት ግምገማ',
|
||||
'Site plan': 'የቦታ ዕቅድ',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// English translations for the EMA portal (user side).
|
||||
// Enum keys (licenseType.*, applicationStatus.*, etc.) match the union values
|
||||
// in features/licenses/types so components can resolve labels with
|
||||
// t(`licenseType.${value}`).
|
||||
|
||||
export const en = {
|
||||
app: {
|
||||
name: 'EMA Portal',
|
||||
@@ -18,10 +13,7 @@ export const en = {
|
||||
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
services: 'Services',
|
||||
apply: 'New Application',
|
||||
applications: 'My Applications',
|
||||
licenses: 'My Licences',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
profile: 'Profile',
|
||||
support: 'Help & Support',
|
||||
},
|
||||
@@ -38,9 +30,6 @@ export const en = {
|
||||
search: 'Search',
|
||||
download: 'Download',
|
||||
print: 'Print',
|
||||
renew: 'Renew',
|
||||
apply: 'Apply',
|
||||
applyNow: 'Apply now',
|
||||
filter: 'Filter',
|
||||
all: 'All',
|
||||
status: 'Status',
|
||||
@@ -55,7 +44,7 @@ export const en = {
|
||||
notFound: 'Not found',
|
||||
learnMore: 'Learn more',
|
||||
toggleTheme: 'Toggle light / dark mode',
|
||||
welcome: 'Welcome back',
|
||||
welcome: 'Welcome',
|
||||
},
|
||||
|
||||
auth: {
|
||||
@@ -67,159 +56,8 @@ export const en = {
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
title: 'My Dashboard',
|
||||
subtitle: 'Overview of your licences, applications and compliance status.',
|
||||
newApplication: 'New Application',
|
||||
recentApplications: 'Recent Applications',
|
||||
applicationsByStatus: 'Applications by status',
|
||||
title: 'Dashboard',
|
||||
quickActions: 'Quick actions',
|
||||
stats: {
|
||||
activeLicenses: 'Active Licences',
|
||||
pendingApplications: 'Pending Applications',
|
||||
expiringSoon: 'Expiring Soon',
|
||||
actionRequired: 'Action Required',
|
||||
},
|
||||
quick: {
|
||||
apply: 'Apply for a licence',
|
||||
applyDesc: 'Start a new application',
|
||||
renew: 'Renew a licence',
|
||||
renewDesc: 'Keep your certificates valid',
|
||||
track: 'Track an application',
|
||||
trackDesc: 'See where your request stands',
|
||||
},
|
||||
hero: {
|
||||
greeting: 'Welcome back',
|
||||
subtitle:
|
||||
'Apply for maritime licences, track your applications and manage your certificates — all in one place.',
|
||||
browse: 'Browse services',
|
||||
},
|
||||
charts: {
|
||||
trend: 'Applications over time',
|
||||
trendPeriod: 'Last 6 months',
|
||||
distribution: 'Status distribution',
|
||||
noData: 'No data to display yet',
|
||||
total: 'Total',
|
||||
},
|
||||
},
|
||||
|
||||
services: {
|
||||
title: 'Maritime Services',
|
||||
subtitle:
|
||||
'Browse the licences and certificates issued by the Ethiopian Maritime Authority and start an application.',
|
||||
seafarer: 'Seafarer certification',
|
||||
vessel: 'Vessel & ship services',
|
||||
facility: 'Port & facility',
|
||||
requiredDocuments: 'Required documents',
|
||||
processingTime: 'Typical processing time',
|
||||
validity: 'Validity',
|
||||
apply: 'Apply',
|
||||
processingDays: '{{min}}–{{max}} working days',
|
||||
validityYears_one: '{{count}} year',
|
||||
validityYears_other: '{{count}} years',
|
||||
categoryLabel: {
|
||||
seafarer: 'Seafarer certification',
|
||||
vessel: 'Vessel & ship services',
|
||||
facility: 'Port & facility',
|
||||
},
|
||||
},
|
||||
|
||||
apply: {
|
||||
title: 'Apply for a Licence',
|
||||
subtitle:
|
||||
'Complete the steps below to submit your application to the Ethiopian Maritime Authority.',
|
||||
steps: {
|
||||
license: 'Licence',
|
||||
licenseDesc: 'Type & request',
|
||||
details: 'Details',
|
||||
detailsDesc: 'Applicant info',
|
||||
documents: 'Documents',
|
||||
documentsDesc: 'Required uploads',
|
||||
review: 'Review',
|
||||
},
|
||||
fields: {
|
||||
requestType: 'Request type',
|
||||
licenseType: 'Licence type',
|
||||
licenseTypePlaceholder: 'Select the licence you need',
|
||||
applicantName: 'Applicant / Company name',
|
||||
applicantPlaceholder: 'e.g. Blue Nile Shipping PLC',
|
||||
notes: 'Additional notes',
|
||||
notesPlaceholder: 'Anything the reviewing officer should know',
|
||||
relatedLicense: 'Existing licence number',
|
||||
},
|
||||
docs: {
|
||||
hint: 'Tick each document once you have attached it. All required documents must be provided before submitting.',
|
||||
attached: 'Documents attached',
|
||||
attachedCount: '{{count}} of {{total}} required documents',
|
||||
},
|
||||
review: {
|
||||
title: 'Review your application',
|
||||
hint: 'Confirm the details below, then submit to EMA.',
|
||||
missingDocs:
|
||||
'Some required documents are still missing. You can still submit, but review may be delayed.',
|
||||
},
|
||||
submitted: 'Your application has been submitted to EMA.',
|
||||
submitAction: 'Submit application',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'My Applications',
|
||||
subtitle: 'Track the status of every request you have submitted to EMA.',
|
||||
empty: 'No applications yet. Start by applying for a licence.',
|
||||
searchPlaceholder: 'Search by reference, subject or applicant',
|
||||
columns: {
|
||||
reference: 'Reference No.',
|
||||
licenseType: 'Licence Type',
|
||||
request: 'Request',
|
||||
subject: 'Subject',
|
||||
submitted: 'Submitted',
|
||||
status: 'Status',
|
||||
},
|
||||
},
|
||||
|
||||
applicationDetail: {
|
||||
title: 'Application',
|
||||
reference: 'Reference number',
|
||||
overview: 'Overview',
|
||||
progress: 'Progress',
|
||||
documents: 'Documents',
|
||||
history: 'History',
|
||||
applicant: 'Applicant',
|
||||
subject: 'Subject',
|
||||
requestType: 'Request type',
|
||||
licenseType: 'Licence type',
|
||||
submittedOn: 'Submitted on',
|
||||
lastUpdated: 'Last updated',
|
||||
officerNote: 'Note from EMA',
|
||||
relatedLicense: 'Related licence',
|
||||
actionRequired: 'Action required',
|
||||
actionRequiredDesc:
|
||||
'EMA has requested more information before your application can proceed.',
|
||||
respond: 'Respond to request',
|
||||
},
|
||||
|
||||
licenses: {
|
||||
title: 'My Licences',
|
||||
subtitle: 'All certificates and licences issued to you by EMA.',
|
||||
empty: 'You have no issued licences yet.',
|
||||
columns: {
|
||||
licenseNo: 'Licence No.',
|
||||
type: 'Type',
|
||||
subject: 'Subject',
|
||||
issued: 'Issued',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
},
|
||||
detail: {
|
||||
holder: 'Licence holder',
|
||||
subject: 'Subject',
|
||||
issuedOn: 'Issued on',
|
||||
expiresOn: 'Expires on',
|
||||
daysLeft: '{{count}} days remaining',
|
||||
expired: 'This licence has expired.',
|
||||
expiringSoon: 'This licence is expiring soon. Renew to stay compliant.',
|
||||
download: 'Download certificate',
|
||||
renew: 'Renew licence',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
@@ -318,88 +156,6 @@ export const en = {
|
||||
a4: 'You will see an "Action required" notice on the application. Provide the requested document or detail to resume the review.',
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Domain enums (keys match the type unions) ----
|
||||
licenseType: {
|
||||
SEAFARER_COC: 'Certificate of Competency (CoC)',
|
||||
SEAFARER_COP: 'Certificate of Proficiency (CoP)',
|
||||
SEAMAN_BOOK: "Seafarer's Identity & Record Book",
|
||||
VESSEL_REGISTRATION: 'Vessel Registration Certificate',
|
||||
SHIP_RADIO: 'Ship Station Radio Licence',
|
||||
TONNAGE_CERTIFICATE: 'International Tonnage Certificate',
|
||||
SAFETY_MANAGEMENT: 'Safety Management Certificate',
|
||||
PORT_FACILITY: 'Port Facility Operation Licence',
|
||||
BOAT_OPERATOR: 'Inland Boat Operator Licence',
|
||||
},
|
||||
|
||||
subjectLabel: {
|
||||
SEAFARER_COC: 'Seafarer full name',
|
||||
SEAFARER_COP: 'Seafarer full name',
|
||||
SEAMAN_BOOK: 'Seafarer full name',
|
||||
VESSEL_REGISTRATION: 'Vessel name',
|
||||
SHIP_RADIO: 'Vessel name',
|
||||
TONNAGE_CERTIFICATE: 'Vessel name',
|
||||
SAFETY_MANAGEMENT: 'Vessel name',
|
||||
PORT_FACILITY: 'Facility name',
|
||||
BOAT_OPERATOR: 'Operator full name',
|
||||
},
|
||||
|
||||
requestType: {
|
||||
NEW: 'New Application',
|
||||
RENEWAL: 'Renewal',
|
||||
AMENDMENT: 'Amendment',
|
||||
COMPLIANCE: 'Compliance Submission',
|
||||
},
|
||||
|
||||
applicationStatus: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
INFO_REQUESTED: 'Information Requested',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ISSUED: 'Issued',
|
||||
},
|
||||
|
||||
licenseStatus: {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRING_SOON: 'Expiring Soon',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
},
|
||||
|
||||
documentStatus: {
|
||||
PENDING: 'Pending',
|
||||
UPLOADED: 'Uploaded',
|
||||
VERIFIED: 'Verified',
|
||||
REJECTED: 'Rejected',
|
||||
},
|
||||
|
||||
documents: {
|
||||
'Passport copy': 'Passport copy',
|
||||
'Passport photo': 'Passport photo',
|
||||
'Training certificate': 'Training certificate',
|
||||
'Course completion certificate': 'Course completion certificate',
|
||||
'Medical fitness certificate': 'Medical fitness certificate',
|
||||
'Sea service record': 'Sea service record',
|
||||
'Police clearance': 'Police clearance',
|
||||
'ID copy': 'ID copy',
|
||||
'Bill of sale': 'Bill of sale',
|
||||
'Builder certificate': 'Builder certificate',
|
||||
'Tonnage measurement': 'Tonnage measurement',
|
||||
'Insurance certificate': 'Insurance certificate',
|
||||
'Vessel registration': 'Vessel registration',
|
||||
'Equipment specification': 'Equipment specification',
|
||||
'Operator certificate': 'Operator certificate',
|
||||
'Survey report': 'Survey report',
|
||||
'General arrangement plan': 'General arrangement plan',
|
||||
'Safety management manual': 'Safety management manual',
|
||||
'Audit report': 'Audit report',
|
||||
'Business licence': 'Business licence',
|
||||
'Facility security assessment': 'Facility security assessment',
|
||||
'Site plan': 'Site plan',
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
AppShell,
|
||||
Burger,
|
||||
Card,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
@@ -17,13 +15,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconBell,
|
||||
IconCertificate,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconFileDescription,
|
||||
IconFolder,
|
||||
IconLayoutDashboard,
|
||||
IconLifebuoy,
|
||||
IconList,
|
||||
@@ -48,11 +42,7 @@ interface NavItem {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/applications', label: 'My Applications', icon: IconFileDescription },
|
||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconList },
|
||||
{ to: '/seafarer-registration', label: 'New Registration', icon: IconAnchor },
|
||||
{ to: '/licenses', label: 'My Licenses', icon: IconCertificate },
|
||||
{ label: 'Documents', icon: IconFolder, soon: true },
|
||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||
{ to: '/support', label: 'Support', icon: IconLifebuoy },
|
||||
];
|
||||
@@ -67,22 +57,10 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
'/applications': {
|
||||
title: 'My Applications',
|
||||
subtitle: 'Track the status of every application',
|
||||
},
|
||||
'/apply': {
|
||||
title: 'Apply for a License',
|
||||
subtitle: 'New application',
|
||||
},
|
||||
'/seafarer-registry': {
|
||||
title: 'Seafarer Registry',
|
||||
subtitle: 'Manage all registered seafarers',
|
||||
},
|
||||
'/seafarer-registration': {
|
||||
title: 'New Seafarer Registration',
|
||||
subtitle: 'Register a new seafarer profile',
|
||||
},
|
||||
'/profile': {
|
||||
title: 'Profile',
|
||||
subtitle: 'Manage your account and preferences',
|
||||
@@ -120,66 +98,144 @@ export function PortalLayout() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const activeNavItem = (item: NavItem) =>
|
||||
!!item.to &&
|
||||
(location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`));
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 74 }}
|
||||
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
|
||||
navbar={{
|
||||
width: sidebarCollapsed ? 72 : 264,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !navOpened },
|
||||
}}
|
||||
padding="lg"
|
||||
>
|
||||
{/* ---- Header ---------------------------------------------------- */}
|
||||
<AppShell.Header>
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Card
|
||||
p="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}
|
||||
{/* Hamburger — styled like user-management Top.tsx */}
|
||||
<UnstyledButton
|
||||
onClick={isMobile ? toggleNav : toggleSidebar}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
{/* Mobile: toggle the nav drawer */}
|
||||
<Burger
|
||||
opened={navOpened}
|
||||
onClick={toggleNav}
|
||||
onClick={() => {}}
|
||||
size="sm"
|
||||
hiddenFrom="sm"
|
||||
aria-label="Toggle navigation"
|
||||
styles={{
|
||||
root: { border: 'none', background: 'transparent' },
|
||||
burger: { '--burger-color': 'currentColor' },
|
||||
}}
|
||||
/>
|
||||
{/* Desktop: toggle the sidebar collapse */}
|
||||
<Burger
|
||||
opened={!sidebarCollapsed}
|
||||
onClick={toggleSidebar}
|
||||
size="sm"
|
||||
visibleFrom="sm"
|
||||
aria-label="Collapse sidebar"
|
||||
/>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Breadcrumbs: Home > … */}
|
||||
<Group gap={6} wrap="nowrap" visibleFrom="xs">
|
||||
{/* Breadcrumbs — card container with pill-style crumbs matching DynamicBreadcrumb */}
|
||||
<Group
|
||||
gap={2}
|
||||
wrap="nowrap"
|
||||
visibleFrom="xs"
|
||||
style={{
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
padding: '4px 10px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1;
|
||||
return (
|
||||
<Group key={crumb.path} gap={6} wrap="nowrap">
|
||||
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
|
||||
{i > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
>
|
||||
</Text>
|
||||
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
|
||||
)}
|
||||
{isLast ? (
|
||||
<Text size="sm" fw={600}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: rem(999),
|
||||
padding: '2px 12px',
|
||||
fontSize: rem(12),
|
||||
fontWeight: 600,
|
||||
background: 'var(--mantine-color-primary-light)',
|
||||
color: 'var(--mantine-color-primary-7)',
|
||||
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '22px',
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</Text>
|
||||
</span>
|
||||
) : (
|
||||
<Anchor
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
c="gray.6"
|
||||
onClick={() => {
|
||||
navigate(crumb.path);
|
||||
closeNav();
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: rem(999),
|
||||
padding: '2px 10px',
|
||||
fontSize: rem(12),
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '22px',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
@@ -188,24 +244,89 @@ export function PortalLayout() {
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<LanguageSwitcher />
|
||||
<ColorSchemeToggle />
|
||||
<Indicator color="red" size={9} offset={5} withBorder>
|
||||
<ActionIcon variant="default" size={38} radius="md" aria-label="Notifications">
|
||||
|
||||
{/* Notification bell — styled like UM action buttons */}
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Indicator color="red" size={9} offset={5} withBorder>
|
||||
<IconBell size={19} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Profile avatar menu */}
|
||||
<Menu position="bottom-end" width={200} shadow="md" withinPortal>
|
||||
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size={38} radius="xl" aria-label="Account menu">
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<BrandAvatar size={38} />
|
||||
</ActionIcon>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Abebe Bekele</Menu.Label>
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
borderRadius: rem(12),
|
||||
padding: rem(6),
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Menu.Label
|
||||
style={{
|
||||
padding: rem(10),
|
||||
fontWeight: 600,
|
||||
fontSize: rem(13),
|
||||
}}
|
||||
>
|
||||
Abebe Bekele
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
onClick={() => navigate('/profile')}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
@@ -214,6 +335,7 @@ export function PortalLayout() {
|
||||
color="red"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogout}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
@@ -224,31 +346,66 @@ export function PortalLayout() {
|
||||
</AppShell.Header>
|
||||
|
||||
{/* ---- Sidebar -------------------------------------------------- */}
|
||||
<AppShell.Navbar p="md" style={{ overflow: 'hidden', transition: 'width 200ms ease' }}>
|
||||
<AppShell.Section pb="md">
|
||||
<Group gap="sm" wrap="nowrap" justify={sidebarCollapsed ? 'center' : undefined}>
|
||||
<BrandMark size={42} style={{ flexShrink: 0 }} />
|
||||
{!sidebarCollapsed && (
|
||||
<div>
|
||||
<Text fw={700} lh={1.1}>
|
||||
EMA Portal
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
Ethiopian Maritime Authority
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</AppShell.Section>
|
||||
<AppShell.Navbar
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'width 200ms ease',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
>
|
||||
{/* Brand header — matching AppSidebar Brand section */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(12),
|
||||
height: 74,
|
||||
padding: sidebarCollapsed ? '0 12px' : '0 20px',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BrandMark size={32} />
|
||||
{!sidebarCollapsed && (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fw={800}
|
||||
size="xs"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1.2,
|
||||
color: 'var(--mantine-color-primary-7)',
|
||||
}}
|
||||
>
|
||||
EMA Portal
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: rem(10),
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
Ethiopian Maritime Authority
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppShell.Section grow component={ScrollArea}>
|
||||
{/* Navigation items — original Mantine NavLink style */}
|
||||
<AppShell.Section grow component={ScrollArea} p="md">
|
||||
<Stack gap={4}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const active =
|
||||
!!item.to &&
|
||||
(location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`));
|
||||
const active = activeNavItem(item);
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
||||
@@ -270,6 +427,7 @@ export function PortalLayout() {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
@@ -285,8 +443,14 @@ export function PortalLayout() {
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<AppShell.Section pt="xs">
|
||||
{/* Collapse toggle — matching AppSidebar collapse button */}
|
||||
<div
|
||||
style={{
|
||||
padding: rem(8),
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
position="right"
|
||||
@@ -299,11 +463,26 @@ export function PortalLayout() {
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||
gap: rem(10),
|
||||
width: '100%',
|
||||
padding: `${rem(10)} ${rem(12)}`,
|
||||
borderRadius: rem(10),
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
padding: '10px 12px',
|
||||
borderRadius: rem(12),
|
||||
color: 'var(--mantine-color-gray-5)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: rem(13),
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
|
||||
}}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
@@ -311,12 +490,12 @@ export function PortalLayout() {
|
||||
) : (
|
||||
<>
|
||||
<IconChevronLeft size={18} stroke={1.6} />
|
||||
<Text size="sm" fw={500}>Collapse</Text>
|
||||
<span>Collapse</span>
|
||||
</>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
</AppShell.Section>
|
||||
</div>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
|
||||
@@ -9,12 +9,6 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
import { ServicesPage } from './features/licenses/pages/ServicesPage';
|
||||
import { ApplyPage } from './features/licenses/pages/ApplyPage';
|
||||
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
||||
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
||||
import { MyLicensesPage } from './features/licenses/pages/MyLicensesPage';
|
||||
import { LicenseDetailPage } from './features/licenses/pages/LicenseDetailPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
import { SupportPage } from './features/support/pages/SupportPage';
|
||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||
@@ -51,15 +45,9 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/apply', element: <ApplyPage /> },
|
||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
||||
{ path: '/applications', element: <ApplicationsListPage /> },
|
||||
{ path: '/applications/:id', element: <ApplicationDetailPage /> },
|
||||
{ path: '/licenses', element: <MyLicensesPage /> },
|
||||
{ path: '/licenses/:id', element: <LicenseDetailPage /> },
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
|
||||
configureAuthStorage('ema-portal');
|
||||
@@ -19,7 +18,6 @@ export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
signup: signupReducer,
|
||||
licenses: licensesReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||
|
||||
Reference in New Issue
Block a user