frontend scalfolding

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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