mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 12:18:12 +00:00
frontend scalfolding
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
145
apps/portal/src/app/features/licenses/constants.ts
Normal file
145
apps/portal/src/app/features/licenses/constants.ts
Normal 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',
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
25
apps/portal/src/app/features/licenses/hooks/useLicenses.ts
Normal file
25
apps/portal/src/app/features/licenses/hooks/useLicenses.ts
Normal 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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
252
apps/portal/src/app/features/licenses/pages/ApplyPage.tsx
Normal file
252
apps/portal/src/app/features/licenses/pages/ApplyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
39
apps/portal/src/app/features/licenses/pages/ServicesPage.tsx
Normal file
39
apps/portal/src/app/features/licenses/pages/ServicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
apps/portal/src/app/features/licenses/store/licenses.slice.ts
Normal file
172
apps/portal/src/app/features/licenses/store/licenses.slice.ts
Normal 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;
|
||||
82
apps/portal/src/app/features/licenses/types/license.types.ts
Normal file
82
apps/portal/src/app/features/licenses/types/license.types.ts
Normal 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[];
|
||||
}
|
||||
109
apps/portal/src/app/features/profile/pages/ProfilePage.tsx
Normal file
109
apps/portal/src/app/features/profile/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/portal/src/app/features/support/pages/SupportPage.tsx
Normal file
77
apps/portal/src/app/features/support/pages/SupportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user