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 { IconSun, IconMoon } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -9,13 +9,37 @@ export function ColorSchemeToggle() {
|
|||||||
const isDark = computed === 'dark';
|
const isDark = computed === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<UnstyledButton
|
||||||
variant="subtle"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t('common.toggleTheme')}
|
aria-label={t('common.toggleTheme')}
|
||||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
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} />}
|
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
|
||||||
</ActionIcon>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Menu, ActionIcon, Button } from '@mantine/core';
|
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
|
||||||
import { IconWorld, IconCheck } from '@tabler/icons-react';
|
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
|
||||||
|
|
||||||
@@ -19,21 +19,51 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
|||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
{variant === 'icon' ? (
|
<UnstyledButton
|
||||||
<ActionIcon variant="subtle" size="lg" aria-label={t('language.label')}>
|
aria-label={t('language.label')}
|
||||||
<IconWorld size={20} />
|
style={{
|
||||||
</ActionIcon>
|
display: 'flex',
|
||||||
) : (
|
alignItems: 'center',
|
||||||
<Button
|
justifyContent: 'center',
|
||||||
variant="subtle"
|
gap: rem(4),
|
||||||
size="sm"
|
width: variant === 'icon' ? rem(38) : 'auto',
|
||||||
leftSection={<IconWorld size={16} />}
|
height: rem(38),
|
||||||
>
|
padding: variant === 'icon' ? 0 : '0 12px',
|
||||||
{t(`language.${current}`)}
|
borderRadius: rem(12),
|
||||||
</Button>
|
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.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown
|
||||||
|
style={{ borderRadius: rem(12), padding: rem(6) }}
|
||||||
|
>
|
||||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||||
{SUPPORTED_LANGUAGES.map((lng) => (
|
{SUPPORTED_LANGUAGES.map((lng) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -42,6 +72,7 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
|
|||||||
rightSection={
|
rightSection={
|
||||||
current === lng ? <IconCheck size={16} /> : undefined
|
current === lng ? <IconCheck size={16} /> : undefined
|
||||||
}
|
}
|
||||||
|
style={{ borderRadius: rem(8) }}
|
||||||
>
|
>
|
||||||
{t(`language.${lng}`)}
|
{t(`language.${lng}`)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -1,33 +1,4 @@
|
|||||||
import { Badge, Box, useMantineTheme } from '@mantine/core';
|
import { 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrandAvatar({
|
export function BrandAvatar({
|
||||||
initials = 'AB',
|
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 {
|
import {
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Grid,
|
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
@@ -16,48 +12,17 @@ import {
|
|||||||
useMantineTheme,
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
|
||||||
IconAward,
|
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconClockHour4,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconFileText,
|
|
||||||
IconLifebuoy,
|
IconLifebuoy,
|
||||||
IconShip,
|
IconShip,
|
||||||
IconSquareRoundedPlus,
|
IconUserPlus,
|
||||||
IconUpload,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const theme = useMantineTheme();
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -71,24 +36,13 @@ export function DashboardPage() {
|
|||||||
<Stack gap="md" maw={560}>
|
<Stack gap="md" maw={560}>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Title order={2} c="white" fz={26}>
|
<Title order={2} c="white" fz={26}>
|
||||||
Welcome back, {firstName} 👋
|
Welcome to the EMA Portal
|
||||||
</Title>
|
</Title>
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||||
You have {pending} applications in review and {activeLicenses} active
|
Manage your seafarer profile, submit applications, and track your
|
||||||
licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check
|
maritime credentials all in one place.
|
||||||
your status below.
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</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>
|
</Stack>
|
||||||
<Center
|
<Center
|
||||||
visibleFrom="sm"
|
visibleFrom="sm"
|
||||||
@@ -101,179 +55,32 @@ export function DashboardPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* ---- Stat cards -------------------------------------------- */}
|
{/* ---- Quick actions ----------------------------------------- */}
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, lg: 4 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
<StatCard
|
<Paper withBorder radius="lg" p="lg">
|
||||||
icon={IconAward}
|
<Title order={4} mb="md">
|
||||||
color="teal"
|
Quick actions
|
||||||
value={activeLicenses}
|
</Title>
|
||||||
label="Active Licenses"
|
<Stack gap="xs">
|
||||||
trend="+1 this year"
|
<QuickAction
|
||||||
trendColor="teal"
|
icon={IconUserPlus}
|
||||||
/>
|
color="emaPrimary"
|
||||||
<StatCard
|
label="Register a new seafarer"
|
||||||
icon={IconClockHour4}
|
onClick={() => navigate('/seafarer-registration')}
|
||||||
color="indigo"
|
/>
|
||||||
value={pending}
|
<QuickAction
|
||||||
label="Pending Applications"
|
icon={IconLifebuoy}
|
||||||
trend="In review"
|
color="orange"
|
||||||
/>
|
label="Contact support"
|
||||||
<StatCard
|
onClick={() => navigate('/support')}
|
||||||
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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Paper>
|
||||||
</Grid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</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({
|
function QuickAction({
|
||||||
icon: ActionIconCmp,
|
icon: ActionIconCmp,
|
||||||
color,
|
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';
|
import type { Translations } from './en';
|
||||||
|
|
||||||
export const am: Translations = {
|
export const am: Translations = {
|
||||||
@@ -19,10 +15,7 @@ export const am: Translations = {
|
|||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'ዳሽቦርድ',
|
dashboard: 'ዳሽቦርድ',
|
||||||
services: 'አገልግሎቶች',
|
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||||
apply: 'አዲስ ማመልከቻ',
|
|
||||||
applications: 'ማመልከቻዎቼ',
|
|
||||||
licenses: 'ፍቃዶቼ',
|
|
||||||
profile: 'መገለጫ',
|
profile: 'መገለጫ',
|
||||||
support: 'እገዛና ድጋፍ',
|
support: 'እገዛና ድጋፍ',
|
||||||
},
|
},
|
||||||
@@ -39,9 +32,6 @@ export const am: Translations = {
|
|||||||
search: 'ፈልግ',
|
search: 'ፈልግ',
|
||||||
download: 'አውርድ',
|
download: 'አውርድ',
|
||||||
print: 'አትም',
|
print: 'አትም',
|
||||||
renew: 'አድስ',
|
|
||||||
apply: 'አመልክት',
|
|
||||||
applyNow: 'አሁን አመልክት',
|
|
||||||
filter: 'አጣራ',
|
filter: 'አጣራ',
|
||||||
all: 'ሁሉም',
|
all: 'ሁሉም',
|
||||||
status: 'ሁኔታ',
|
status: 'ሁኔታ',
|
||||||
@@ -68,159 +58,8 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'ዳሽቦርዴ',
|
title: 'ዳሽቦርድ',
|
||||||
subtitle: 'የፍቃዶችዎ፣ የማመልከቻዎችዎና የተገዢነት ሁኔታ አጠቃላይ እይታ።',
|
|
||||||
newApplication: 'አዲስ ማመልከቻ',
|
|
||||||
recentApplications: 'የቅርብ ጊዜ ማመልከቻዎች',
|
|
||||||
applicationsByStatus: 'ማመልከቻዎች በሁኔታ',
|
|
||||||
quickActions: 'ፈጣን ድርጊቶች',
|
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: {
|
profile: {
|
||||||
@@ -319,86 +158,4 @@ export const am: Translations = {
|
|||||||
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
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 = {
|
export const en = {
|
||||||
app: {
|
app: {
|
||||||
name: 'EMA Portal',
|
name: 'EMA Portal',
|
||||||
@@ -18,10 +13,7 @@ export const en = {
|
|||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
services: 'Services',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
apply: 'New Application',
|
|
||||||
applications: 'My Applications',
|
|
||||||
licenses: 'My Licences',
|
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
support: 'Help & Support',
|
support: 'Help & Support',
|
||||||
},
|
},
|
||||||
@@ -38,9 +30,6 @@ export const en = {
|
|||||||
search: 'Search',
|
search: 'Search',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
print: 'Print',
|
print: 'Print',
|
||||||
renew: 'Renew',
|
|
||||||
apply: 'Apply',
|
|
||||||
applyNow: 'Apply now',
|
|
||||||
filter: 'Filter',
|
filter: 'Filter',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
@@ -55,7 +44,7 @@ export const en = {
|
|||||||
notFound: 'Not found',
|
notFound: 'Not found',
|
||||||
learnMore: 'Learn more',
|
learnMore: 'Learn more',
|
||||||
toggleTheme: 'Toggle light / dark mode',
|
toggleTheme: 'Toggle light / dark mode',
|
||||||
welcome: 'Welcome back',
|
welcome: 'Welcome',
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
@@ -67,159 +56,8 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'My Dashboard',
|
title: 'Dashboard',
|
||||||
subtitle: 'Overview of your licences, applications and compliance status.',
|
|
||||||
newApplication: 'New Application',
|
|
||||||
recentApplications: 'Recent Applications',
|
|
||||||
applicationsByStatus: 'Applications by status',
|
|
||||||
quickActions: 'Quick actions',
|
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: {
|
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.',
|
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;
|
export type Translations = typeof en;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Anchor,
|
Anchor,
|
||||||
AppShell,
|
AppShell,
|
||||||
Burger,
|
Burger,
|
||||||
Card,
|
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
Indicator,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -17,13 +15,9 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconAnchor,
|
|
||||||
IconBell,
|
IconBell,
|
||||||
IconCertificate,
|
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconFileDescription,
|
|
||||||
IconFolder,
|
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconLifebuoy,
|
IconLifebuoy,
|
||||||
IconList,
|
IconList,
|
||||||
@@ -48,11 +42,7 @@ interface NavItem {
|
|||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||||
{ to: '/applications', label: 'My Applications', icon: IconFileDescription },
|
|
||||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconList },
|
{ 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: '/profile', label: 'Profile', icon: IconUser },
|
||||||
{ to: '/support', label: 'Support', icon: IconLifebuoy },
|
{ to: '/support', label: 'Support', icon: IconLifebuoy },
|
||||||
];
|
];
|
||||||
@@ -67,22 +57,10 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'/applications': {
|
|
||||||
title: 'My Applications',
|
|
||||||
subtitle: 'Track the status of every application',
|
|
||||||
},
|
|
||||||
'/apply': {
|
|
||||||
title: 'Apply for a License',
|
|
||||||
subtitle: 'New application',
|
|
||||||
},
|
|
||||||
'/seafarer-registry': {
|
'/seafarer-registry': {
|
||||||
title: 'Seafarer Registry',
|
title: 'Seafarer Registry',
|
||||||
subtitle: 'Manage all registered seafarers',
|
subtitle: 'Manage all registered seafarers',
|
||||||
},
|
},
|
||||||
'/seafarer-registration': {
|
|
||||||
title: 'New Seafarer Registration',
|
|
||||||
subtitle: 'Register a new seafarer profile',
|
|
||||||
},
|
|
||||||
'/profile': {
|
'/profile': {
|
||||||
title: 'Profile',
|
title: 'Profile',
|
||||||
subtitle: 'Manage your account and preferences',
|
subtitle: 'Manage your account and preferences',
|
||||||
@@ -120,66 +98,144 @@ export function PortalLayout() {
|
|||||||
navigate('/login');
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 74 }}
|
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"
|
padding="lg"
|
||||||
>
|
>
|
||||||
{/* ---- Header ---------------------------------------------------- */}
|
{/* ---- 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 h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
<Card
|
{/* Hamburger — styled like user-management Top.tsx */}
|
||||||
p="xs"
|
<UnstyledButton
|
||||||
radius="md"
|
onClick={isMobile ? toggleNav : toggleSidebar}
|
||||||
withBorder
|
style={{
|
||||||
style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}
|
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
|
<Burger
|
||||||
opened={navOpened}
|
opened={navOpened}
|
||||||
onClick={toggleNav}
|
onClick={() => {}}
|
||||||
size="sm"
|
size="sm"
|
||||||
hiddenFrom="sm"
|
|
||||||
aria-label="Toggle navigation"
|
aria-label="Toggle navigation"
|
||||||
|
styles={{
|
||||||
|
root: { border: 'none', background: 'transparent' },
|
||||||
|
burger: { '--burger-color': 'currentColor' },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Desktop: toggle the sidebar collapse */}
|
</UnstyledButton>
|
||||||
<Burger
|
|
||||||
opened={!sidebarCollapsed}
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
size="sm"
|
|
||||||
visibleFrom="sm"
|
|
||||||
aria-label="Collapse sidebar"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Breadcrumbs: Home > … */}
|
{/* Breadcrumbs — card container with pill-style crumbs matching DynamicBreadcrumb */}
|
||||||
<Group gap={6} wrap="nowrap" visibleFrom="xs">
|
<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) => {
|
{crumbs.map((crumb, i) => {
|
||||||
const isLast = i === crumbs.length - 1;
|
const isLast = i === crumbs.length - 1;
|
||||||
return (
|
return (
|
||||||
<Group key={crumb.path} gap={6} wrap="nowrap">
|
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<Text size="sm" c="dimmed">
|
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
|
||||||
>
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
{isLast ? (
|
{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}
|
{crumb.label}
|
||||||
</Text>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
c="dimmed"
|
c="gray.6"
|
||||||
onClick={() => navigate(crumb.path)}
|
onClick={() => {
|
||||||
style={{ cursor: 'pointer' }}
|
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}
|
{crumb.label}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -188,24 +244,89 @@ export function PortalLayout() {
|
|||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<ColorSchemeToggle />
|
<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} />
|
<IconBell size={19} />
|
||||||
</ActionIcon>
|
</Indicator>
|
||||||
</Indicator>
|
</UnstyledButton>
|
||||||
|
|
||||||
{/* Profile avatar menu */}
|
{/* Profile avatar menu */}
|
||||||
<Menu position="bottom-end" width={200} shadow="md" withinPortal>
|
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
|
||||||
<Menu.Target>
|
<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} />
|
<BrandAvatar size={38} />
|
||||||
</ActionIcon>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown
|
||||||
<Menu.Label>Abebe Bekele</Menu.Label>
|
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
|
<Menu.Item
|
||||||
leftSection={<IconUserCircle size={16} />}
|
leftSection={<IconUserCircle size={16} />}
|
||||||
onClick={() => navigate('/profile')}
|
onClick={() => navigate('/profile')}
|
||||||
|
style={{ borderRadius: rem(8) }}
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -214,6 +335,7 @@ export function PortalLayout() {
|
|||||||
color="red"
|
color="red"
|
||||||
leftSection={<IconLogout size={16} />}
|
leftSection={<IconLogout size={16} />}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
style={{ borderRadius: rem(8) }}
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -224,31 +346,66 @@ export function PortalLayout() {
|
|||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
{/* ---- Sidebar -------------------------------------------------- */}
|
{/* ---- Sidebar -------------------------------------------------- */}
|
||||||
<AppShell.Navbar p="md" style={{ overflow: 'hidden', transition: 'width 200ms ease' }}>
|
<AppShell.Navbar
|
||||||
<AppShell.Section pb="md">
|
p={0}
|
||||||
<Group gap="sm" wrap="nowrap" justify={sidebarCollapsed ? 'center' : undefined}>
|
style={{
|
||||||
<BrandMark size={42} style={{ flexShrink: 0 }} />
|
overflow: 'hidden',
|
||||||
{!sidebarCollapsed && (
|
transition: 'width 200ms ease',
|
||||||
<div>
|
background: 'var(--mantine-color-body)',
|
||||||
<Text fw={700} lh={1.1}>
|
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||||
EMA Portal
|
}}
|
||||||
</Text>
|
>
|
||||||
<Text size="xs" c="dimmed" lh={1.2}>
|
{/* Brand header — matching AppSidebar Brand section */}
|
||||||
Ethiopian Maritime Authority
|
<div
|
||||||
</Text>
|
style={{
|
||||||
</div>
|
display: 'flex',
|
||||||
)}
|
alignItems: 'center',
|
||||||
</Group>
|
gap: rem(12),
|
||||||
</AppShell.Section>
|
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}>
|
<Stack gap={4}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
const active =
|
const active = activeNavItem(item);
|
||||||
!!item.to &&
|
|
||||||
(location.pathname === item.to ||
|
|
||||||
location.pathname.startsWith(`${item.to}/`));
|
|
||||||
if (sidebarCollapsed) {
|
if (sidebarCollapsed) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
||||||
@@ -270,6 +427,7 @@ export function PortalLayout() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -285,8 +443,14 @@ export function PortalLayout() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
{/* Collapse toggle — matching AppSidebar collapse button */}
|
||||||
<AppShell.Section pt="xs">
|
<div
|
||||||
|
style={{
|
||||||
|
padding: rem(8),
|
||||||
|
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
position="right"
|
position="right"
|
||||||
@@ -299,11 +463,26 @@ export function PortalLayout() {
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||||
gap: rem(10),
|
gap: rem(10),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: `${rem(10)} ${rem(12)}`,
|
padding: '10px 12px',
|
||||||
borderRadius: rem(10),
|
borderRadius: rem(12),
|
||||||
color: 'var(--mantine-color-dimmed)',
|
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 ? (
|
{sidebarCollapsed ? (
|
||||||
@@ -311,12 +490,12 @@ export function PortalLayout() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconChevronLeft size={18} stroke={1.6} />
|
<IconChevronLeft size={18} stroke={1.6} />
|
||||||
<Text size="sm" fw={500}>Collapse</Text>
|
<span>Collapse</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</AppShell.Section>
|
</div>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
|
|||||||
|
|
||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||||
import { ServicesPage } from './features/licenses/pages/ServicesPage';
|
|
||||||
import { ApplyPage } from './features/licenses/pages/ApplyPage';
|
|
||||||
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
|
||||||
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
|
||||||
import { MyLicensesPage } from './features/licenses/pages/MyLicensesPage';
|
|
||||||
import { LicenseDetailPage } from './features/licenses/pages/LicenseDetailPage';
|
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||||
import { SupportPage } from './features/support/pages/SupportPage';
|
import { SupportPage } from './features/support/pages/SupportPage';
|
||||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||||
@@ -51,15 +45,9 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
{ path: '/services', element: <ServicesPage /> },
|
|
||||||
{ path: '/apply', element: <ApplyPage /> },
|
|
||||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
{ 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: '/profile', element: <ProfilePage /> },
|
||||||
{ path: '/support', element: <SupportPage /> },
|
{ path: '/support', element: <SupportPage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { baseApi } from '@ema-platform/api';
|
import { baseApi } from '@ema-platform/api';
|
||||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
|
|
||||||
configureAuthStorage('ema-portal');
|
configureAuthStorage('ema-portal');
|
||||||
@@ -19,7 +18,6 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
signup: signupReducer,
|
signup: signupReducer,
|
||||||
licenses: licensesReducer,
|
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
[baseApi.reducerPath]: baseApi.reducer,
|
||||||
},
|
},
|
||||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user