mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 11:55:43 +00:00
update the protal dashboard
This commit is contained in:
@@ -23,7 +23,9 @@
|
|||||||
"Bash(git log *)",
|
"Bash(git log *)",
|
||||||
"Bash(grep -rl '_$_1e42\\\\|sfL\\(' --exclude-dir=node_modules --exclude-dir=.git .)",
|
"Bash(grep -rl '_$_1e42\\\\|sfL\\(' --exclude-dir=node_modules --exclude-dir=.git .)",
|
||||||
"Bash(grep -c '_$_1e42')",
|
"Bash(grep -c '_$_1e42')",
|
||||||
"Bash(node_modules/.bin/tsc -p apps/backoffice/tsconfig.json --noEmit)"
|
"Bash(node_modules/.bin/tsc -p apps/backoffice/tsconfig.json --noEmit)",
|
||||||
|
"Bash(python3 -m json.tool)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k:d[k] for k in ['workspaces','name'] if k in d}, indent=2\\)\\)\")"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/home/tria/projects/mengisteab/emaui"
|
"/home/tria/projects/mengisteab/emaui"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Badge, Box, useMantineTheme } from '@mantine/core';
|
import { Badge, Box, useMantineTheme } from '@mantine/core';
|
||||||
import { APPLICATION_STATUS_COLORS } from '../../features/licenses/constants';
|
import { APPLICATION_STATUS_COLORS } from '../features/licenses/constants';
|
||||||
import type { ApplicationStatus } from '../../features/licenses/types/license.types';
|
import type { ApplicationStatus } from '../features/licenses/types/license.types';
|
||||||
import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels';
|
import { useLicenseLabels } from '../features/licenses/hooks/useLicenseLabels';
|
||||||
|
|
||||||
/** Pill status badge with a colored dot — matches the design system. */
|
|
||||||
export function StatusPill({ status }: { status: ApplicationStatus }) {
|
export function StatusPill({ status }: { status: ApplicationStatus }) {
|
||||||
const { applicationStatus } = useLicenseLabels();
|
const { applicationStatus } = useLicenseLabels();
|
||||||
const color = APPLICATION_STATUS_COLORS[status];
|
const color = APPLICATION_STATUS_COLORS[status];
|
||||||
@@ -30,7 +29,6 @@ export function StatusPill({ status }: { status: ApplicationStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Circular avatar filled with the EMA brand gradient. */
|
|
||||||
export function BrandAvatar({
|
export function BrandAvatar({
|
||||||
initials = 'AB',
|
initials = 'AB',
|
||||||
size = 38,
|
size = 38,
|
||||||
@@ -1,36 +1,40 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Center,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCertificate,
|
|
||||||
IconClockHour4,
|
|
||||||
IconFilePlus,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconRefresh,
|
IconAward,
|
||||||
IconSearch,
|
IconChevronRight,
|
||||||
|
IconClockHour4,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileText,
|
||||||
|
IconLifebuoy,
|
||||||
|
IconShip,
|
||||||
|
IconSquareRoundedPlus,
|
||||||
|
IconUpload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { StatCard } from '../../licenses/components/StatCard';
|
|
||||||
import { ApplicationsTable } from '../../licenses/components/ApplicationsTable';
|
|
||||||
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
||||||
import { DashboardHero } from '../components/DashboardHero';
|
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||||
import { StatusDonut } from '../components/StatusDonut';
|
import { StatusDonut } from '../components/StatusDonut';
|
||||||
import { ApplicationsTrend } from '../components/ApplicationsTrend';
|
|
||||||
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
||||||
|
import { StatusPill } from '../../../components/ui';
|
||||||
|
|
||||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
const OPEN_STATUSES: ApplicationStatus[] = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
@@ -40,159 +44,260 @@ const OPEN_STATUSES: ApplicationStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const theme = useMantineTheme();
|
||||||
const { applications, licenses } = useLicenses();
|
const { applications, licenses } = useLicenses();
|
||||||
|
const { licenseType, formatDate } = useLicenseLabels();
|
||||||
// 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 activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
||||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
||||||
const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length;
|
const approved = applications.filter(
|
||||||
const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length;
|
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||||
|
).length;
|
||||||
|
const documents = applications.reduce((sum, a) => sum + a.documents.length, 0);
|
||||||
|
|
||||||
const recent = applications.slice(0, 5);
|
const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0];
|
||||||
const holderName = licenses[0]?.holderName;
|
const recent = applications.slice(0, 6);
|
||||||
|
|
||||||
const quickActions: {
|
|
||||||
icon: Icon;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
to: string;
|
|
||||||
color: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
icon: IconFilePlus,
|
|
||||||
title: t('dashboard.quick.apply'),
|
|
||||||
desc: t('dashboard.quick.applyDesc'),
|
|
||||||
to: '/apply',
|
|
||||||
color: 'emaPrimary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconRefresh,
|
|
||||||
title: t('dashboard.quick.renew'),
|
|
||||||
desc: t('dashboard.quick.renewDesc'),
|
|
||||||
to: '/licenses',
|
|
||||||
color: 'emaTeal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: IconSearch,
|
|
||||||
title: t('dashboard.quick.track'),
|
|
||||||
desc: t('dashboard.quick.trackDesc'),
|
|
||||||
to: '/applications',
|
|
||||||
color: 'indigo',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<DashboardHero name={holderName} />
|
{/* ---- Hero banner ------------------------------------------- */}
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
p="xl"
|
||||||
|
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="center">
|
||||||
|
<Stack gap="md" maw={560}>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Title order={2} c="white" fz={26}>
|
||||||
|
Welcome back, {firstName} 👋
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||||
|
You have {pending} applications in review and {activeLicenses} active
|
||||||
|
licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check
|
||||||
|
your status below.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
w="fit-content"
|
||||||
|
color="white"
|
||||||
|
c="emaPrimary.7"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<IconSquareRoundedPlus size={18} />}
|
||||||
|
onClick={() => navigate('/apply')}
|
||||||
|
>
|
||||||
|
Apply for a license
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Center
|
||||||
|
visibleFrom="sm"
|
||||||
|
w={120}
|
||||||
|
h={120}
|
||||||
|
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.15)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<IconShip size={62} color="white" stroke={1.4} />
|
||||||
|
</Center>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
{/* ---- Stat cards -------------------------------------------- */}
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, lg: 4 }} spacing="lg">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('dashboard.stats.activeLicenses')}
|
icon={IconAward}
|
||||||
value={activeLicenses}
|
|
||||||
icon={IconCertificate}
|
|
||||||
color="teal"
|
color="teal"
|
||||||
|
value={activeLicenses}
|
||||||
|
label="Active Licenses"
|
||||||
|
trend="+1 this year"
|
||||||
|
trendColor="teal"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('dashboard.stats.pendingApplications')}
|
|
||||||
value={pending}
|
|
||||||
icon={IconClockHour4}
|
icon={IconClockHour4}
|
||||||
color="indigo"
|
color="indigo"
|
||||||
|
value={pending}
|
||||||
|
label="Pending Applications"
|
||||||
|
trend="In review"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('dashboard.stats.expiringSoon')}
|
icon={IconCircleCheck}
|
||||||
value={expiring}
|
color="teal"
|
||||||
icon={IconAlertTriangle}
|
value={approved}
|
||||||
color="orange"
|
label="Approved"
|
||||||
|
trend="100% pass"
|
||||||
|
trendColor="teal"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('dashboard.stats.actionRequired')}
|
icon={IconFileText}
|
||||||
value={actionRequired}
|
color="emaPrimary"
|
||||||
icon={IconAlertTriangle}
|
value={documents}
|
||||||
color="red"
|
label="Documents"
|
||||||
|
trend="Up to date"
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
|
{/* ---- Lower row --------------------------------------------- */}
|
||||||
{quickActions.map((qa) => (
|
<Grid gutter="lg">
|
||||||
<UnstyledButton key={qa.to} onClick={() => navigate(qa.to)}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" mb="md">
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Title order={4}>Recent Applications</Title>
|
||||||
<ThemeIcon variant="light" color={qa.color} size={42}>
|
<Button
|
||||||
<qa.icon size={22} />
|
variant="subtle"
|
||||||
</ThemeIcon>
|
size="compact-sm"
|
||||||
<div>
|
rightSection={<IconArrowRight size={15} />}
|
||||||
<Text fw={600}>{qa.title}</Text>
|
onClick={() => navigate('/applications')}
|
||||||
<Text size="sm" c="dimmed">
|
>
|
||||||
{qa.desc}
|
View all
|
||||||
</Text>
|
</Button>
|
||||||
</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>
|
</Group>
|
||||||
) : (
|
|
||||||
<StatusDonut applications={applications} />
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Paper p="lg" shadow="sm" withBorder>
|
<Table verticalSpacing="sm" highlightOnHover>
|
||||||
<Group justify="space-between" mb="sm">
|
<Table.Thead>
|
||||||
<Title order={4}>{t('dashboard.recentApplications')}</Title>
|
<Table.Tr>
|
||||||
<Button
|
<Table.Th>Application</Table.Th>
|
||||||
variant="subtle"
|
<Table.Th>Type</Table.Th>
|
||||||
size="sm"
|
<Table.Th>Submitted</Table.Th>
|
||||||
rightSection={<IconArrowRight size={16} />}
|
<Table.Th>Status</Table.Th>
|
||||||
onClick={() => navigate('/applications')}
|
<Table.Th w={40} />
|
||||||
>
|
</Table.Tr>
|
||||||
{t('common.viewAll')}
|
</Table.Thead>
|
||||||
</Button>
|
<Table.Tbody>
|
||||||
</Group>
|
{recent.map((app) => (
|
||||||
<ApplicationsTable applications={recent} />
|
<Table.Tr
|
||||||
</Paper>
|
key={app.id}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate('/applications')}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} fz="sm">
|
||||||
|
{app.referenceNo}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{licenseType(app.licenseType)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{formatDate(app.submittedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusPill status={app.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Application Status
|
||||||
|
</Title>
|
||||||
|
<StatusDonut applications={applications} />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Quick actions
|
||||||
|
</Title>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<QuickAction
|
||||||
|
icon={IconSquareRoundedPlus}
|
||||||
|
color="emaPrimary"
|
||||||
|
label="Apply for new license"
|
||||||
|
onClick={() => navigate('/apply')}
|
||||||
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={IconUpload}
|
||||||
|
color="teal"
|
||||||
|
label="Upload a document"
|
||||||
|
onClick={() => notify.info('Document upload — coming soon.')}
|
||||||
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={IconLifebuoy}
|
||||||
|
color="orange"
|
||||||
|
label="Contact support"
|
||||||
|
onClick={() => navigate('/support')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: CardIcon,
|
||||||
|
color,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
trend,
|
||||||
|
trendColor = 'gray',
|
||||||
|
}: {
|
||||||
|
icon: Icon;
|
||||||
|
color: string;
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
trend: string;
|
||||||
|
trendColor?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" className="ema-hover-lift">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<ThemeIcon variant="light" color={color} size={42} radius="md">
|
||||||
|
<CardIcon size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="xs" fw={600} c={trendColor === 'gray' ? 'dimmed' : trendColor}>
|
||||||
|
{trend}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz={30} fw={700} lh={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAction({
|
||||||
|
icon: ActionIconCmp,
|
||||||
|
color,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: Icon;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton onClick={onClick}>
|
||||||
|
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color={color} size={38} radius="md">
|
||||||
|
<ActionIconCmp size={19} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" fw={500} flex={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,83 +1,349 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button, Group, Paper, Select, Stack, TextInput } from '@mantine/core';
|
import {
|
||||||
import { IconFilePlus, IconSearch } from '@tabler/icons-react';
|
ActionIcon,
|
||||||
import { useNavigate } from 'react-router-dom';
|
Badge,
|
||||||
import { useTranslation } from 'react-i18next';
|
Box,
|
||||||
import { PageHeader } from '../../../components/PageHeader';
|
Button,
|
||||||
import { ApplicationsTable } from '../components/ApplicationsTable';
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Timeline,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAdjustmentsHorizontal,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronRight,
|
||||||
|
IconClock,
|
||||||
|
IconCircleX,
|
||||||
|
IconDownload,
|
||||||
|
IconMessageCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
import { useLicenses } from '../hooks/useLicenses';
|
import { useLicenses } from '../hooks/useLicenses';
|
||||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||||
import { APPLICATION_STATUS_LABELS } from '../constants';
|
import { APPLICATION_PIPELINE } from '../constants';
|
||||||
import type { ApplicationStatus } from '../types/license.types';
|
import type {
|
||||||
|
Application,
|
||||||
|
ApplicationStatus,
|
||||||
|
LicenseType,
|
||||||
|
} from '../types/license.types';
|
||||||
|
import { StatusPill } from '../../../components/ui';
|
||||||
|
|
||||||
const STATUS_VALUES = Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[];
|
const SERVICE_FEE: Record<LicenseType, number> = {
|
||||||
|
SEAFARER_COC: 1200,
|
||||||
|
SEAFARER_COP: 900,
|
||||||
|
SEAMAN_BOOK: 600,
|
||||||
|
VESSEL_REGISTRATION: 3500,
|
||||||
|
SHIP_RADIO: 900,
|
||||||
|
TONNAGE_CERTIFICATE: 2400,
|
||||||
|
SAFETY_MANAGEMENT: 5000,
|
||||||
|
PORT_FACILITY: 5000,
|
||||||
|
BOAT_OPERATOR: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const etb = (n: number) => `ETB ${n.toLocaleString()}`;
|
||||||
|
|
||||||
|
const PIPELINE_LABELS: Record<string, string> = {
|
||||||
|
SUBMITTED: 'Application submitted',
|
||||||
|
UNDER_REVIEW: 'Document verification & review',
|
||||||
|
PAYMENT_PENDING: 'Payment & assessment',
|
||||||
|
APPROVED: 'Approval decision',
|
||||||
|
ISSUED: 'License issued',
|
||||||
|
};
|
||||||
|
|
||||||
|
function stageIndex(status: ApplicationStatus): number {
|
||||||
|
if (status === 'DRAFT') return 0;
|
||||||
|
if (status === 'INFO_REQUESTED') return 1;
|
||||||
|
if (status === 'REJECTED') return 3;
|
||||||
|
const i = APPLICATION_PIPELINE.indexOf(status);
|
||||||
|
return i < 0 ? 0 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = 'all' | 'review' | 'approved' | 'pending';
|
||||||
|
|
||||||
export function ApplicationsListPage() {
|
export function ApplicationsListPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { applications } = useLicenses();
|
const { applications } = useLicenses();
|
||||||
const { applicationStatus } = useLicenseLabels();
|
const { licenseType, formatDate } = useLicenseLabels();
|
||||||
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(
|
||||||
|
applications[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const counts = useMemo(
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
() => ({
|
||||||
|
all: applications.length,
|
||||||
|
review: applications.filter((a) => a.status === 'UNDER_REVIEW').length,
|
||||||
|
approved: applications.filter(
|
||||||
|
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||||
|
).length,
|
||||||
|
pending: applications.filter((a) =>
|
||||||
|
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
||||||
|
).length,
|
||||||
|
}),
|
||||||
|
[applications],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
switch (tab) {
|
||||||
return applications.filter((app) => {
|
case 'review':
|
||||||
const matchesStatus = !status || app.status === status;
|
return applications.filter((a) => a.status === 'UNDER_REVIEW');
|
||||||
const matchesQuery =
|
case 'approved':
|
||||||
!q ||
|
return applications.filter(
|
||||||
app.referenceNo.toLowerCase().includes(q) ||
|
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
||||||
app.subjectName.toLowerCase().includes(q) ||
|
);
|
||||||
app.applicantName.toLowerCase().includes(q);
|
case 'pending':
|
||||||
return matchesStatus && matchesQuery;
|
return applications.filter((a) =>
|
||||||
});
|
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
||||||
}, [applications, query, status]);
|
);
|
||||||
|
default:
|
||||||
|
return applications;
|
||||||
|
}
|
||||||
|
}, [applications, tab]);
|
||||||
|
|
||||||
const statusOptions = [
|
const selected =
|
||||||
{ value: '', label: t('common.all') },
|
applications.find((a) => a.id === selectedId) ?? filtered[0] ?? applications[0];
|
||||||
...STATUS_VALUES.map((s) => ({ value: s, label: applicationStatus(s) })),
|
|
||||||
|
const TABS: { key: TabKey; label: string; count: number }[] = [
|
||||||
|
{ key: 'all', label: 'All', count: counts.all },
|
||||||
|
{ key: 'review', label: 'In Review', count: counts.review },
|
||||||
|
{ key: 'approved', label: 'Approved', count: counts.approved },
|
||||||
|
{ key: 'pending', label: 'Pending', count: counts.pending },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<Group justify="space-between">
|
||||||
title={t('applications.title')}
|
<Group gap="xs">
|
||||||
subtitle={t('applications.subtitle')}
|
{TABS.map((tabItem) => {
|
||||||
action={
|
const active = tab === tabItem.key;
|
||||||
<Button
|
return (
|
||||||
leftSection={<IconFilePlus size={18} />}
|
<Button
|
||||||
onClick={() => navigate('/apply')}
|
key={tabItem.key}
|
||||||
>
|
size="xs"
|
||||||
{t('dashboard.newApplication')}
|
radius="xl"
|
||||||
</Button>
|
variant={active ? 'filled' : 'default'}
|
||||||
}
|
onClick={() => setTab(tabItem.key)}
|
||||||
/>
|
rightSection={
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
variant={active ? 'white' : 'light'}
|
||||||
|
color={active ? 'emaPrimary' : 'gray'}
|
||||||
|
>
|
||||||
|
{tabItem.count}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tabItem.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<IconAdjustmentsHorizontal size={16} />}
|
||||||
|
>
|
||||||
|
Filter & sort
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
<Grid gutter="lg" align="start">
|
||||||
<Stack gap="md">
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Group gap="sm" wrap="wrap">
|
<Paper withBorder radius="lg" p="xs">
|
||||||
<TextInput
|
<Table verticalSpacing="md" horizontalSpacing="md">
|
||||||
flex={1}
|
<Table.Thead>
|
||||||
miw={220}
|
<Table.Tr>
|
||||||
leftSection={<IconSearch size={16} />}
|
<Table.Th>Application</Table.Th>
|
||||||
placeholder={t('applications.searchPlaceholder')}
|
<Table.Th>Type</Table.Th>
|
||||||
value={query}
|
<Table.Th>Submitted</Table.Th>
|
||||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
<Table.Th>Fee</Table.Th>
|
||||||
/>
|
<Table.Th>Status</Table.Th>
|
||||||
<Select
|
<Table.Th w={36} />
|
||||||
w={220}
|
</Table.Tr>
|
||||||
placeholder={t('common.status')}
|
</Table.Thead>
|
||||||
data={statusOptions}
|
<Table.Tbody>
|
||||||
value={status ?? ''}
|
{filtered.map((app) => {
|
||||||
onChange={(v) => setStatus(v || null)}
|
const isSelected = selected?.id === app.id;
|
||||||
clearable={false}
|
return (
|
||||||
/>
|
<Table.Tr
|
||||||
</Group>
|
key={app.id}
|
||||||
|
onClick={() => setSelectedId(app.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isSelected
|
||||||
|
? 'var(--mantine-color-emaPrimary-light)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} fz="sm">
|
||||||
|
{app.referenceNo}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{licenseType(app.licenseType)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{formatDate(app.submittedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{etb(SERVICE_FEE[app.licenseType])}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusPill status={app.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
<ApplicationsTable applications={filtered} />
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
{selected && <TimelinePanel application={selected} />}
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelinePanel({ application }: { application: Application }) {
|
||||||
|
const { licenseType, formatDate } = useLicenseLabels();
|
||||||
|
const current = stageIndex(application.status);
|
||||||
|
const historyDates = new Map(
|
||||||
|
application.history.map((h) => [h.status, h.date] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" mb="sm">
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{application.referenceNo}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{licenseType(application.licenseType)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={application.status} />
|
||||||
|
</Group>
|
||||||
|
<Divider mb="lg" />
|
||||||
|
|
||||||
|
<Timeline active={current} bulletSize={26} lineWidth={2} color="emaPrimary">
|
||||||
|
{APPLICATION_PIPELINE.map((status, i) => {
|
||||||
|
const done = i < current;
|
||||||
|
const isCurrent = i === current;
|
||||||
|
const date = historyDates.get(status);
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
key={status}
|
||||||
|
bullet={done ? <IconCheck size={14} /> : undefined}
|
||||||
|
title={
|
||||||
|
<Text fw={isCurrent || done ? 600 : 500} fz="sm">
|
||||||
|
{PIPELINE_LABELS[status]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
lineVariant={done ? 'solid' : 'dashed'}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fz="xs"
|
||||||
|
c={isCurrent ? 'emaPrimary' : 'dimmed'}
|
||||||
|
fw={isCurrent ? 600 : 400}
|
||||||
|
>
|
||||||
|
{date
|
||||||
|
? formatDate(date)
|
||||||
|
: isCurrent
|
||||||
|
? 'In progress'
|
||||||
|
: 'Pending'}
|
||||||
|
</Text>
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
mt="lg"
|
||||||
|
p="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
background: 'var(--mantine-color-indigo-light)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon variant="transparent" color="indigo" size="sm">
|
||||||
|
<IconClock size={17} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" fw={500} c="indigo">
|
||||||
|
Last updated {formatDate(application.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<ActionRow
|
||||||
|
icon={<IconDownload size={18} />}
|
||||||
|
label="Download submission receipt"
|
||||||
|
onClick={() => notify.info('Receipt download — coming soon.')}
|
||||||
|
/>
|
||||||
|
<ActionRow
|
||||||
|
icon={<IconMessageCircle size={18} />}
|
||||||
|
label="Message case officer"
|
||||||
|
onClick={() => notify.info('Messaging — coming soon.')}
|
||||||
|
/>
|
||||||
|
<ActionRow
|
||||||
|
icon={<IconCircleX size={18} />}
|
||||||
|
label="Withdraw application"
|
||||||
|
danger
|
||||||
|
onClick={() => notify.info('Withdrawal — coming soon.')}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
danger,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton onClick={onClick} p="xs" style={{ borderRadius: 'var(--mantine-radius-sm)' }}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box c={danger ? 'red' : 'dimmed'} style={{ display: 'flex' }}>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Text fz="sm" fw={500} flex={1} c={danger ? 'red' : undefined}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<IconChevronRight size={15} style={{ opacity: 0.4 }} />
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Center,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
@@ -12,40 +16,87 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconCircleCheck, IconInfoCircle } from '@tabler/icons-react';
|
import {
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
IconArrowLeft,
|
||||||
import { useTranslation } from 'react-i18next';
|
IconArrowRight,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCloudUpload,
|
||||||
|
IconDeviceMobile,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconLifebuoy,
|
||||||
|
IconMapPin,
|
||||||
|
IconMessageCircle,
|
||||||
|
IconShip,
|
||||||
|
IconStack2,
|
||||||
|
IconUser,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { REQUIRED_DOCUMENTS } from '../constants';
|
|
||||||
import { PageHeader } from '../../../components/PageHeader';
|
|
||||||
import { useLicenses } from '../hooks/useLicenses';
|
import { useLicenses } from '../hooks/useLicenses';
|
||||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||||
|
import {
|
||||||
|
LICENSE_PROCESSING_DAYS,
|
||||||
|
LICENSE_TYPE_LABELS,
|
||||||
|
LICENSE_VALIDITY_YEARS,
|
||||||
|
REQUEST_TYPE_LABELS,
|
||||||
|
REQUIRED_DOCUMENTS,
|
||||||
|
} from '../constants';
|
||||||
import type { LicenseType, RequestType } from '../types/license.types';
|
import type { LicenseType, RequestType } from '../types/license.types';
|
||||||
import { LICENSE_TYPE_LABELS } from '../constants';
|
|
||||||
|
|
||||||
const APPLY_REQUEST_TYPES: RequestType[] = ['NEW', 'RENEWAL'];
|
const SERVICE_FEE: Record<LicenseType, number> = {
|
||||||
const LICENSE_TYPE_VALUES = Object.keys(LICENSE_TYPE_LABELS) as LicenseType[];
|
SEAFARER_COC: 1200,
|
||||||
|
SEAFARER_COP: 900,
|
||||||
|
SEAMAN_BOOK: 600,
|
||||||
|
VESSEL_REGISTRATION: 3500,
|
||||||
|
SHIP_RADIO: 900,
|
||||||
|
TONNAGE_CERTIFICATE: 2400,
|
||||||
|
SAFETY_MANAGEMENT: 5000,
|
||||||
|
PORT_FACILITY: 5000,
|
||||||
|
BOAT_OPERATOR: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other'];
|
||||||
|
const REGIONS = [
|
||||||
|
'Addis Ababa',
|
||||||
|
'Dire Dawa',
|
||||||
|
'Amhara',
|
||||||
|
'Oromia',
|
||||||
|
'Tigray',
|
||||||
|
'Afar',
|
||||||
|
'Somali',
|
||||||
|
'Sidama',
|
||||||
|
'South Ethiopia',
|
||||||
|
'Gambela',
|
||||||
|
'Benishangul-Gumuz',
|
||||||
|
'Harari',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: LICENSE_TYPE_LABELS[v],
|
||||||
|
}));
|
||||||
|
const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: REQUEST_TYPE_LABELS[v],
|
||||||
|
}));
|
||||||
|
|
||||||
export function ApplyPage() {
|
export function ApplyPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
|
||||||
const { submit } = useLicenses();
|
const { submit } = useLicenses();
|
||||||
const { licenseType: ltLabel, subjectLabel, requestType: rtLabel, doc } =
|
const { subjectLabel, doc } = useLicenseLabels();
|
||||||
useLicenseLabels();
|
|
||||||
|
|
||||||
const presetType = params.get('type') as LicenseType | null;
|
|
||||||
const presetRequest = params.get('request') as RequestType | null;
|
|
||||||
|
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [requestType, setRequestType] = useState<RequestType>(
|
const [licenseType, setLicenseType] = useState<LicenseType | null>('SEAFARER_COC');
|
||||||
presetRequest && APPLY_REQUEST_TYPES.includes(presetRequest) ? presetRequest : 'NEW',
|
const [category, setCategory] = useState<string | null>('Deck Officer');
|
||||||
);
|
const [region, setRegion] = useState<string | null>('Addis Ababa');
|
||||||
const [licenseType, setLicenseType] = useState<LicenseType | null>(
|
const [requestType, setRequestType] = useState<RequestType>('NEW');
|
||||||
presetType && LICENSE_TYPE_VALUES.includes(presetType) ? presetType : null,
|
const [fullName, setFullName] = useState('');
|
||||||
);
|
const [idNumber, setIdNumber] = useState('');
|
||||||
const [applicantName, setApplicantName] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [subjectName, setSubjectName] = useState('');
|
const [subjectName, setSubjectName] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
|
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
|
||||||
@@ -54,190 +105,323 @@ export function ApplyPage() {
|
|||||||
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
||||||
[licenseType],
|
[licenseType],
|
||||||
);
|
);
|
||||||
|
const attached = requiredDocs.filter((d) => uploaded[d]).length;
|
||||||
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 next = () => setActive((c) => Math.min(c + 1, 3));
|
||||||
const prev = () => setActive((c) => Math.max(c - 1, 0));
|
const prev = () => setActive((c) => Math.max(c - 1, 0));
|
||||||
|
|
||||||
const toggleDoc = (name: string) =>
|
const canContinue = () => {
|
||||||
setUploaded((prevState) => ({ ...prevState, [name]: !prevState[name] }));
|
if (active === 0) return !!licenseType && !!fullName.trim();
|
||||||
|
if (active === 1) return !!subjectName.trim();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!licenseType) return;
|
if (!licenseType) return;
|
||||||
submit({
|
submit({
|
||||||
requestType,
|
requestType,
|
||||||
licenseType,
|
licenseType,
|
||||||
applicantName: applicantName.trim(),
|
applicantName: fullName.trim(),
|
||||||
subjectName: subjectName.trim(),
|
subjectName: subjectName.trim() || fullName.trim(),
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
documents: requiredDocs.filter((d) => uploaded[d]),
|
documents: requiredDocs.filter((d) => uploaded[d]),
|
||||||
});
|
});
|
||||||
notify.success(t('apply.submitted'));
|
notify.success('Your application has been submitted to EMA.');
|
||||||
navigate('/applications');
|
navigate('/applications');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" maw={860} mx="auto">
|
<Stack gap="lg">
|
||||||
<PageHeader title={t('apply.title')} subtitle={t('apply.subtitle')} />
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Stepper active={active} onStepClick={setActive} size="sm" iconSize={34}>
|
||||||
<Paper p="xl" shadow="sm" radius="md" withBorder>
|
<Stepper.Step label="Service & Applicant" description="Step 1" />
|
||||||
<Stepper active={active} onStepClick={setActive} size="sm">
|
<Stepper.Step label="License Details" description="Step 2" />
|
||||||
<Stepper.Step
|
<Stepper.Step label="Documents" description="Step 3" />
|
||||||
label={t('apply.steps.license')}
|
<Stepper.Step label="Review & Submit" description="Step 4" />
|
||||||
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>
|
</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>
|
</Paper>
|
||||||
|
|
||||||
|
<Grid gutter="lg" align="start">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead
|
||||||
|
title="Service & Applicant Details"
|
||||||
|
subtitle="Choose the license you need and tell us who the application is for."
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="License type"
|
||||||
|
leftSection={<IconShip size={18} />}
|
||||||
|
data={LICENSE_OPTIONS}
|
||||||
|
value={licenseType}
|
||||||
|
onChange={(v) => setLicenseType(v as LicenseType)}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<Select
|
||||||
|
label="Category"
|
||||||
|
leftSection={<IconStack2 size={18} />}
|
||||||
|
data={CATEGORIES}
|
||||||
|
value={category}
|
||||||
|
onChange={setCategory}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Region"
|
||||||
|
leftSection={<IconMapPin size={18} />}
|
||||||
|
data={REGIONS}
|
||||||
|
value={region}
|
||||||
|
onChange={setRegion}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<TextInput
|
||||||
|
label="Full name (as on ID)"
|
||||||
|
leftSection={<IconUser size={18} />}
|
||||||
|
placeholder="Abebe Bekele Tadesse"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="National ID / Passport"
|
||||||
|
leftSection={<IconId size={18} />}
|
||||||
|
placeholder="ET-1234567"
|
||||||
|
value={idNumber}
|
||||||
|
onChange={(e) => setIdNumber(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Phone number"
|
||||||
|
leftSection={<IconDeviceMobile size={18} />}
|
||||||
|
placeholder="+251 911 234 567"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Dropzone />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead
|
||||||
|
title="License Details"
|
||||||
|
subtitle="A few more details about this specific request."
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Request type"
|
||||||
|
data={REQUEST_OPTIONS}
|
||||||
|
value={requestType}
|
||||||
|
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||||
|
placeholder={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||||
|
value={subjectName}
|
||||||
|
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Additional notes (optional)"
|
||||||
|
placeholder="Anything the reviewing officer should know"
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SectionHead
|
||||||
|
title="Required Documents"
|
||||||
|
subtitle="Tick each document once you have attached it."
|
||||||
|
/>
|
||||||
|
<Alert variant="light" color="emaPrimary" icon={<IconInfoCircle size={18} />}>
|
||||||
|
All required documents must be provided before the review can be completed.
|
||||||
|
</Alert>
|
||||||
|
{requiredDocs.map((d) => (
|
||||||
|
<Checkbox
|
||||||
|
key={d}
|
||||||
|
label={doc(d)}
|
||||||
|
checked={!!uploaded[d]}
|
||||||
|
onChange={() =>
|
||||||
|
setUploaded((s) => ({ ...s, [d]: !s[d] }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead
|
||||||
|
title="Review & Submit"
|
||||||
|
subtitle="Confirm the details below, then submit to EMA."
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<ReviewItem label="License type" value={licenseType ? LICENSE_TYPE_LABELS[licenseType] : '—'} />
|
||||||
|
<ReviewItem label="Request type" value={REQUEST_TYPE_LABELS[requestType]} />
|
||||||
|
<ReviewItem label="Full name" value={fullName || '—'} />
|
||||||
|
<ReviewItem label="Region" value={region ?? '—'} />
|
||||||
|
<ReviewItem label="Subject" value={subjectName || '—'} />
|
||||||
|
<ReviewItem
|
||||||
|
label="Documents attached"
|
||||||
|
value={`${attached} of ${requiredDocs.length}`}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
{attached < requiredDocs.length && (
|
||||||
|
<Alert variant="light" color="orange">
|
||||||
|
Some required documents are still missing. You can still submit, but
|
||||||
|
review may be delayed.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconArrowLeft size={18} />}
|
||||||
|
onClick={prev}
|
||||||
|
disabled={active === 0}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
{active < 3 ? (
|
||||||
|
<Button
|
||||||
|
rightSection={<IconArrowRight size={18} />}
|
||||||
|
onClick={next}
|
||||||
|
disabled={!canContinue()}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconCircleCheck size={18} />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Application Summary
|
||||||
|
</Title>
|
||||||
|
<Group
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
mb="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
background: 'var(--mantine-color-emaPrimary-light)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon variant="filled" color="emaPrimary" size={40} radius="md">
|
||||||
|
<IconShip size={21} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={700} fz="sm" truncate>
|
||||||
|
{licenseType ? LICENSE_TYPE_LABELS[licenseType] : 'Select a license'}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{category ?? '—'} · {REQUEST_TYPE_LABELS[requestType]}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SummaryRow
|
||||||
|
label="Processing time"
|
||||||
|
value={
|
||||||
|
licenseType
|
||||||
|
? `${LICENSE_PROCESSING_DAYS[licenseType][0]}–${LICENSE_PROCESSING_DAYS[licenseType][1]} working days`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
label="License validity"
|
||||||
|
value={
|
||||||
|
licenseType ? `${LICENSE_VALIDITY_YEARS[licenseType]} years` : '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryRow
|
||||||
|
label="Service fee"
|
||||||
|
value={licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fw={700}>Total payable</Text>
|
||||||
|
<Text fw={700} fz="lg" c="emaPrimary">
|
||||||
|
{licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper radius="lg" p="lg" bg="var(--mantine-color-emaTeal-light)">
|
||||||
|
<Group gap="sm" mb="xs">
|
||||||
|
<ThemeIcon variant="filled" color="emaTeal.8" size={36} radius="md">
|
||||||
|
<IconLifebuoy size={19} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} c="emaTeal.8">
|
||||||
|
Need help?
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed" mb="md" lh={1.55}>
|
||||||
|
Our support team can guide you through the documents required for this
|
||||||
|
license.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="white"
|
||||||
|
color="emaTeal.8"
|
||||||
|
leftSection={<IconMessageCircle size={16} />}
|
||||||
|
onClick={() => navigate('/support')}
|
||||||
|
>
|
||||||
|
Contact support
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title, subtitle }: { title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title order={4}>{title}</Title>
|
||||||
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={600} ta="right">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewItem({ label, value }: { label: string; value: string }) {
|
function ReviewItem({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -250,3 +434,35 @@ function ReviewItem({ label, value }: { label: string; value: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Dropzone() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="sm" fw={600} c="dimmed" mb={6}>
|
||||||
|
Supporting document
|
||||||
|
</Text>
|
||||||
|
<Center
|
||||||
|
p="xl"
|
||||||
|
style={{
|
||||||
|
border: '1.5px dashed var(--mantine-color-default-border)',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
background: 'var(--mantine-color-default-hover)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => notify.info('File upload — coming soon.')}
|
||||||
|
>
|
||||||
|
<Stack gap={8} align="center">
|
||||||
|
<ThemeIcon variant="light" color="emaPrimary" size={46} radius="xl">
|
||||||
|
<IconCloudUpload size={23} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" fw={600}>
|
||||||
|
Drag & drop files here, or click to browse
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
PDF, JPG or PNG — up to 10 MB
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,136 +1,260 @@
|
|||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
AppShell,
|
AppShell,
|
||||||
Burger,
|
Burger,
|
||||||
Group,
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Menu,
|
||||||
NavLink,
|
NavLink,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Menu,
|
Tooltip,
|
||||||
Avatar,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
rem,
|
rem,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLayoutDashboard,
|
IconBell,
|
||||||
IconCategory,
|
|
||||||
IconFilePlus,
|
|
||||||
IconFileDescription,
|
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconUser,
|
IconChevronLeft,
|
||||||
IconHelpCircle,
|
|
||||||
IconLogin,
|
|
||||||
IconLogout,
|
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconFileDescription,
|
||||||
|
IconFolder,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconLifebuoy,
|
||||||
|
IconLogout,
|
||||||
|
IconPencil,
|
||||||
|
IconUser,
|
||||||
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Logo } from '../components/Logo';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||||
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
||||||
|
import { BrandMark } from '@ema-platform/auth';
|
||||||
|
import { BrandAvatar } from '../components/ui';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
label: string;
|
||||||
labelKey: string;
|
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
|
to?: string;
|
||||||
|
soon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: IconLayoutDashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||||
{ to: '/services', labelKey: 'nav.services', icon: IconCategory },
|
{ to: '/applications', label: 'My Applications', icon: IconFileDescription },
|
||||||
{ to: '/apply', labelKey: 'nav.apply', icon: IconFilePlus },
|
{ to: '/apply', label: 'Apply for License', icon: IconPencil },
|
||||||
{ to: '/applications', labelKey: 'nav.applications', icon: IconFileDescription },
|
{ to: '/licenses', label: 'My Licenses', icon: IconCertificate },
|
||||||
{ to: '/licenses', labelKey: 'nav.licenses', icon: IconCertificate },
|
{ label: 'Documents', icon: IconFolder, soon: true },
|
||||||
{ to: '/profile', labelKey: 'nav.profile', icon: IconUser },
|
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||||
{ to: '/support', labelKey: 'nav.support', icon: IconHelpCircle },
|
{ to: '/support', label: 'Support', icon: IconLifebuoy },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TOKEN_KEY = 'ema-portal-auth-token';
|
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
'/dashboard': {
|
||||||
|
title: 'Dashboard',
|
||||||
|
subtitle: new Date().toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'/applications': {
|
||||||
|
title: 'My Applications',
|
||||||
|
subtitle: 'Track the status of every application',
|
||||||
|
},
|
||||||
|
'/apply': {
|
||||||
|
title: 'Apply for a License',
|
||||||
|
subtitle: 'New application',
|
||||||
|
},
|
||||||
|
'/profile': {
|
||||||
|
title: 'Profile',
|
||||||
|
subtitle: 'Manage your account and preferences',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function PortalLayout() {
|
export function PortalLayout() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||||
|
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
|
||||||
|
|
||||||
const isLoggedIn = !!localStorage.getItem(TOKEN_KEY);
|
const meta = PAGE_META[location.pathname] ?? {
|
||||||
|
title: 'EMA Portal',
|
||||||
const go = (to: string) => {
|
subtitle: 'Ethiopian Maritime Authority',
|
||||||
navigate(to);
|
|
||||||
close();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const go = (item: NavItem) => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
if (item.soon) {
|
||||||
|
notify.info(`${item.label} — coming soon.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.to) {
|
||||||
|
navigate(item.to);
|
||||||
|
closeNav();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 60 }}
|
header={{ height: 74 }}
|
||||||
navbar={{
|
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
|
||||||
width: 260,
|
padding="lg"
|
||||||
breakpoint: 'sm',
|
|
||||||
collapsed: { mobile: !opened },
|
|
||||||
}}
|
|
||||||
padding="md"
|
|
||||||
>
|
>
|
||||||
|
{/* ---- Header ---------------------------------------------------- */}
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger opened={navOpened} onClick={toggleNav} hiddenFrom="sm" size="sm" />
|
||||||
<UnstyledButton onClick={() => go('/dashboard')}>
|
<div>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Text fw={700} fz="lg" lh={1.15}>
|
||||||
<Logo variant="color" size={36} />
|
{meta.title}
|
||||||
<div>
|
</Text>
|
||||||
<Text fw={700} lh={1.1}>
|
<Text size="xs" c="dimmed" lh={1.2} visibleFrom="xs">
|
||||||
{t('app.name')}
|
{meta.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" lh={1.1} visibleFrom="xs">
|
</div>
|
||||||
{t('app.authority')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ColorSchemeToggle />
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<UserMenu isLoggedIn={isLoggedIn} onLogin={() => navigate('/login')} onLogout={logout} />
|
<ColorSchemeToggle />
|
||||||
|
<Indicator color="red" size={9} offset={5} withBorder>
|
||||||
|
<ActionIcon variant="default" size={38} radius="md" aria-label="Notifications">
|
||||||
|
<IconBell size={19} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{/* Profile avatar menu */}
|
||||||
|
<Menu position="bottom-end" width={200} shadow="md" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="transparent" size={38} radius="xl" aria-label="Account menu">
|
||||||
|
<BrandAvatar size={38} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Abebe Bekele</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconUserCircle size={16} />}
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
color="red"
|
||||||
|
leftSection={<IconLogout size={16} />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
<AppShell.Navbar p="sm">
|
{/* ---- Sidebar -------------------------------------------------- */}
|
||||||
|
<AppShell.Navbar p="md" style={{ overflow: 'hidden', transition: 'width 200ms ease' }}>
|
||||||
|
<AppShell.Section pb="md">
|
||||||
|
<Group gap="sm" wrap="nowrap" justify={sidebarCollapsed ? 'center' : undefined}>
|
||||||
|
<BrandMark size={42} style={{ flexShrink: 0 }} />
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div>
|
||||||
|
<Text fw={700} lh={1.1}>
|
||||||
|
EMA Portal
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
Ethiopian Maritime Authority
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</AppShell.Section>
|
||||||
|
|
||||||
<AppShell.Section grow component={ScrollArea}>
|
<AppShell.Section grow component={ScrollArea}>
|
||||||
<Stack gap={2}>
|
<Stack gap={4}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const ActiveIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
const active =
|
const active =
|
||||||
location.pathname === item.to ||
|
!!item.to &&
|
||||||
location.pathname.startsWith(`${item.to}/`);
|
(location.pathname === item.to ||
|
||||||
|
location.pathname.startsWith(`${item.to}/`));
|
||||||
|
if (sidebarCollapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => go(item)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: rem(10),
|
||||||
|
color: active ? 'var(--mantine-color-blue-6)' : undefined,
|
||||||
|
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemIcon size={20} stroke={1.6} />
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.label}
|
||||||
active={active}
|
active={active}
|
||||||
label={t(item.labelKey)}
|
label={item.label}
|
||||||
leftSection={<ActiveIcon size={20} stroke={1.6} />}
|
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||||
onClick={() => go(item.to)}
|
onClick={() => go(item)}
|
||||||
variant="light"
|
variant="light"
|
||||||
styles={{ root: { borderRadius: rem(8) } }}
|
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
|
||||||
<AppShell.Section>
|
{/* Collapse toggle */}
|
||||||
<Text size="xs" c="dimmed" ta="center" py="xs">
|
<AppShell.Section pt="xs">
|
||||||
{t('app.authority')}
|
<Tooltip
|
||||||
</Text>
|
label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
position="right"
|
||||||
|
withArrow
|
||||||
|
disabled={!sidebarCollapsed}
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
visibleFrom="sm"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: rem(10),
|
||||||
|
width: '100%',
|
||||||
|
padding: `${rem(10)} ${rem(12)}`,
|
||||||
|
borderRadius: rem(10),
|
||||||
|
color: 'var(--mantine-color-dimmed)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<IconChevronRight size={18} stroke={1.6} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconChevronLeft size={18} stroke={1.6} />
|
||||||
|
<Text size="sm" fw={500}>Collapse</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
@@ -142,45 +266,3 @@ export function PortalLayout() {
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu({
|
|
||||||
isLoggedIn,
|
|
||||||
onLogin,
|
|
||||||
onLogout,
|
|
||||||
}: {
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
onLogin: () => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu shadow="md" width={200} position="bottom-end" withinPortal>
|
|
||||||
<Menu.Target>
|
|
||||||
<UnstyledButton>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Avatar color="emaPrimary" radius="xl" size={34}>
|
|
||||||
{isLoggedIn ? 'U' : <IconUser size={18} />}
|
|
||||||
</Avatar>
|
|
||||||
<IconChevronRight size={14} style={{ opacity: 0.5 }} />
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Label>{isLoggedIn ? t('auth.account') : t('auth.guest')}</Menu.Label>
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconLogout size={16} />}
|
|
||||||
onClick={onLogout}
|
|
||||||
>
|
|
||||||
{t('auth.logout')}
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
<Menu.Item leftSection={<IconLogin size={16} />} onClick={onLogin}>
|
|
||||||
{t('auth.login')}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '
|
|||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||||
import { ServicesPage } from './features/licenses/pages/ServicesPage';
|
import { ServicesPage } from './features/licenses/pages/ServicesPage';
|
||||||
|
|
||||||
// Redesigned portal pages (new look & feel) — kept alongside the originals.
|
|
||||||
import { PortalLayoutV2 } from './v2/PortalLayoutV2';
|
|
||||||
import { DashboardV2 } from './v2/pages/DashboardV2';
|
|
||||||
import { ApplicationsV2 } from './v2/pages/ApplicationsV2';
|
|
||||||
import { ApplyV2 } from './v2/pages/ApplyV2';
|
|
||||||
import { ApplyPage } from './features/licenses/pages/ApplyPage';
|
import { ApplyPage } from './features/licenses/pages/ApplyPage';
|
||||||
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
import { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
||||||
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
||||||
@@ -43,9 +37,8 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||||
|
|
||||||
// Portal (open access for this UI template).
|
// Portal — wrapped in the portal's own i18n instance so it is isolated from
|
||||||
// Wrapped in the portal's own i18n instance so it is isolated from the
|
// the global i18next singleton that `@tria-plc/iamui-common` initializes.
|
||||||
// global i18next singleton that `@tria-plc/iamui-common` initializes.
|
|
||||||
{
|
{
|
||||||
element: (
|
element: (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
@@ -66,22 +59,6 @@ export const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Redesigned portal (new look & feel) under /v2 — isolated in the portal i18n
|
|
||||||
// instance, same as the original portal routes.
|
|
||||||
{
|
|
||||||
element: (
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<PortalLayoutV2 />
|
|
||||||
</I18nextProvider>
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
{ path: '/v2', element: <Navigate to="/v2/dashboard" replace /> },
|
|
||||||
{ path: '/v2/dashboard', element: <DashboardV2 /> },
|
|
||||||
{ path: '/v2/applications', element: <ApplicationsV2 /> },
|
|
||||||
{ path: '/v2/apply', element: <ApplyV2 /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// IAM admin user management (isolated providers)
|
// IAM admin user management (isolated providers)
|
||||||
{
|
{
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
AppShell,
|
|
||||||
Box,
|
|
||||||
Burger,
|
|
||||||
Group,
|
|
||||||
Indicator,
|
|
||||||
NavLink,
|
|
||||||
ScrollArea,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
rem,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import {
|
|
||||||
IconBell,
|
|
||||||
IconCertificate,
|
|
||||||
IconFileDescription,
|
|
||||||
IconFolder,
|
|
||||||
IconLayoutDashboard,
|
|
||||||
IconLifebuoy,
|
|
||||||
IconLogout,
|
|
||||||
IconPencil,
|
|
||||||
IconSearch,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import type { Icon } from '@tabler/icons-react';
|
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
|
||||||
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
|
||||||
import { BrandMark } from '@ema-platform/auth';
|
|
||||||
import { BrandAvatar } from './components/ui';
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
label: string;
|
|
||||||
icon: Icon;
|
|
||||||
to?: string;
|
|
||||||
soon?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
|
||||||
{ to: '/v2/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
|
||||||
{ to: '/v2/applications', label: 'My Applications', icon: IconFileDescription },
|
|
||||||
{ to: '/v2/apply', label: 'Apply for License', icon: IconPencil },
|
|
||||||
{ to: '/licenses', label: 'My Licenses', icon: IconCertificate },
|
|
||||||
{ label: 'Documents', icon: IconFolder, soon: true },
|
|
||||||
{ to: '/support', label: 'Support', icon: IconLifebuoy },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
|
||||||
'/v2/dashboard': {
|
|
||||||
title: 'Dashboard',
|
|
||||||
subtitle: new Date().toLocaleDateString('en-GB', {
|
|
||||||
weekday: 'long',
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
'/v2/applications': {
|
|
||||||
title: 'My Applications',
|
|
||||||
subtitle: 'Track the status of every application',
|
|
||||||
},
|
|
||||||
'/v2/apply': {
|
|
||||||
title: 'Apply for a License',
|
|
||||||
subtitle: 'New application',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PortalLayoutV2() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
|
||||||
|
|
||||||
const meta = PAGE_META[location.pathname] ?? {
|
|
||||||
title: 'EMA Portal',
|
|
||||||
subtitle: 'Ethiopian Maritime Authority',
|
|
||||||
};
|
|
||||||
|
|
||||||
const go = (item: NavItem) => {
|
|
||||||
if (item.soon) {
|
|
||||||
notify.info(`${item.label} — coming soon.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.to) {
|
|
||||||
navigate(item.to);
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell
|
|
||||||
header={{ height: 74 }}
|
|
||||||
navbar={{ width: 264, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
|
||||||
padding="lg"
|
|
||||||
>
|
|
||||||
{/* ---- Topbar ---------------------------------------------------- */}
|
|
||||||
<AppShell.Header>
|
|
||||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
|
||||||
<div>
|
|
||||||
<Text fw={700} fz="lg" lh={1.15}>
|
|
||||||
{meta.title}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" lh={1.2} visibleFrom="xs">
|
|
||||||
{meta.subtitle}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
visibleFrom="md"
|
|
||||||
w={240}
|
|
||||||
radius="md"
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
placeholder="Search applications..."
|
|
||||||
/>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<ColorSchemeToggle />
|
|
||||||
<Indicator color="red" size={9} offset={5} withBorder>
|
|
||||||
<ActionIcon variant="default" size={38} radius="md" aria-label="Notifications">
|
|
||||||
<IconBell size={19} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Indicator>
|
|
||||||
<BrandAvatar size={38} />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</AppShell.Header>
|
|
||||||
|
|
||||||
{/* ---- Sidebar -------------------------------------------------- */}
|
|
||||||
<AppShell.Navbar p="md">
|
|
||||||
<AppShell.Section pb="md">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<BrandMark size={42} />
|
|
||||||
<div>
|
|
||||||
<Text fw={700} lh={1.1}>
|
|
||||||
EMA Portal
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" lh={1.2}>
|
|
||||||
Ethiopian Maritime Authority
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</AppShell.Section>
|
|
||||||
|
|
||||||
<AppShell.Section grow component={ScrollArea}>
|
|
||||||
<Stack gap={4}>
|
|
||||||
{NAV_ITEMS.map((item) => {
|
|
||||||
const ItemIcon = item.icon;
|
|
||||||
const active =
|
|
||||||
!!item.to &&
|
|
||||||
(location.pathname === item.to ||
|
|
||||||
location.pathname.startsWith(`${item.to}/`));
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.label}
|
|
||||||
active={active}
|
|
||||||
label={item.label}
|
|
||||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
|
||||||
onClick={() => go(item)}
|
|
||||||
variant="light"
|
|
||||||
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</AppShell.Section>
|
|
||||||
|
|
||||||
<AppShell.Section>
|
|
||||||
<Group
|
|
||||||
gap="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
borderRadius: rem(12),
|
|
||||||
background: 'var(--mantine-color-default-hover)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrandAvatar size={38} />
|
|
||||||
<Box flex={1} style={{ minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={600} truncate>
|
|
||||||
Abebe Bekele
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Applicant
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label="Log out"
|
|
||||||
onClick={() => navigate('/login')}
|
|
||||||
>
|
|
||||||
<IconLogout size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</AppShell.Section>
|
|
||||||
</AppShell.Navbar>
|
|
||||||
|
|
||||||
<AppShell.Main>
|
|
||||||
<div key={location.pathname} className="ema-page-enter">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</AppShell.Main>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Grid,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Timeline,
|
|
||||||
Title,
|
|
||||||
UnstyledButton,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconAdjustmentsHorizontal,
|
|
||||||
IconCheck,
|
|
||||||
IconChevronRight,
|
|
||||||
IconClock,
|
|
||||||
IconCircleX,
|
|
||||||
IconDownload,
|
|
||||||
IconMessageCircle,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { useLicenses } from '../../features/licenses/hooks/useLicenses';
|
|
||||||
import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels';
|
|
||||||
import { APPLICATION_PIPELINE } from '../../features/licenses/constants';
|
|
||||||
import type {
|
|
||||||
Application,
|
|
||||||
ApplicationStatus,
|
|
||||||
LicenseType,
|
|
||||||
} from '../../features/licenses/types/license.types';
|
|
||||||
import { StatusPill } from '../components/ui';
|
|
||||||
|
|
||||||
const SERVICE_FEE: Record<LicenseType, number> = {
|
|
||||||
SEAFARER_COC: 1200,
|
|
||||||
SEAFARER_COP: 900,
|
|
||||||
SEAMAN_BOOK: 600,
|
|
||||||
VESSEL_REGISTRATION: 3500,
|
|
||||||
SHIP_RADIO: 900,
|
|
||||||
TONNAGE_CERTIFICATE: 2400,
|
|
||||||
SAFETY_MANAGEMENT: 5000,
|
|
||||||
PORT_FACILITY: 5000,
|
|
||||||
BOAT_OPERATOR: 700,
|
|
||||||
};
|
|
||||||
|
|
||||||
const etb = (n: number) => `ETB ${n.toLocaleString()}`;
|
|
||||||
|
|
||||||
const PIPELINE_LABELS: Record<string, string> = {
|
|
||||||
SUBMITTED: 'Application submitted',
|
|
||||||
UNDER_REVIEW: 'Document verification & review',
|
|
||||||
PAYMENT_PENDING: 'Payment & assessment',
|
|
||||||
APPROVED: 'Approval decision',
|
|
||||||
ISSUED: 'License issued',
|
|
||||||
};
|
|
||||||
|
|
||||||
function stageIndex(status: ApplicationStatus): number {
|
|
||||||
if (status === 'DRAFT') return 0;
|
|
||||||
if (status === 'INFO_REQUESTED') return 1;
|
|
||||||
if (status === 'REJECTED') return 3;
|
|
||||||
const i = APPLICATION_PIPELINE.indexOf(status);
|
|
||||||
return i < 0 ? 0 : i;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabKey = 'all' | 'review' | 'approved' | 'pending';
|
|
||||||
|
|
||||||
export function ApplicationsV2() {
|
|
||||||
const { applications } = useLicenses();
|
|
||||||
const { licenseType, formatDate } = useLicenseLabels();
|
|
||||||
const [tab, setTab] = useState<TabKey>('all');
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
|
||||||
applications[0]?.id ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const counts = useMemo(
|
|
||||||
() => ({
|
|
||||||
all: applications.length,
|
|
||||||
review: applications.filter((a) => a.status === 'UNDER_REVIEW').length,
|
|
||||||
approved: applications.filter(
|
|
||||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
|
||||||
).length,
|
|
||||||
pending: applications.filter((a) =>
|
|
||||||
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
|
||||||
).length,
|
|
||||||
}),
|
|
||||||
[applications],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
switch (tab) {
|
|
||||||
case 'review':
|
|
||||||
return applications.filter((a) => a.status === 'UNDER_REVIEW');
|
|
||||||
case 'approved':
|
|
||||||
return applications.filter(
|
|
||||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
|
||||||
);
|
|
||||||
case 'pending':
|
|
||||||
return applications.filter((a) =>
|
|
||||||
['SUBMITTED', 'INFO_REQUESTED', 'PAYMENT_PENDING'].includes(a.status),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return applications;
|
|
||||||
}
|
|
||||||
}, [applications, tab]);
|
|
||||||
|
|
||||||
const selected =
|
|
||||||
applications.find((a) => a.id === selectedId) ?? filtered[0] ?? applications[0];
|
|
||||||
|
|
||||||
const TABS: { key: TabKey; label: string; count: number }[] = [
|
|
||||||
{ key: 'all', label: 'All', count: counts.all },
|
|
||||||
{ key: 'review', label: 'In Review', count: counts.review },
|
|
||||||
{ key: 'approved', label: 'Approved', count: counts.approved },
|
|
||||||
{ key: 'pending', label: 'Pending', count: counts.pending },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* ---- Filter tabs ------------------------------------------- */}
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Group gap="xs">
|
|
||||||
{TABS.map((tabItem) => {
|
|
||||||
const active = tab === tabItem.key;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={tabItem.key}
|
|
||||||
size="xs"
|
|
||||||
radius="xl"
|
|
||||||
variant={active ? 'filled' : 'default'}
|
|
||||||
onClick={() => setTab(tabItem.key)}
|
|
||||||
rightSection={
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
radius="xl"
|
|
||||||
variant={active ? 'white' : 'light'}
|
|
||||||
color={active ? 'emaPrimary' : 'gray'}
|
|
||||||
>
|
|
||||||
{tabItem.count}
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tabItem.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<IconAdjustmentsHorizontal size={16} />}
|
|
||||||
>
|
|
||||||
Filter & sort
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Grid gutter="lg" align="start">
|
|
||||||
{/* ---- Applications table ---------------------------------- */}
|
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
||||||
<Paper withBorder radius="lg" p="xs">
|
|
||||||
<Table verticalSpacing="md" horizontalSpacing="md">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Application</Table.Th>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
<Table.Th>Submitted</Table.Th>
|
|
||||||
<Table.Th>Fee</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th w={36} />
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{filtered.map((app) => {
|
|
||||||
const isSelected = selected?.id === app.id;
|
|
||||||
return (
|
|
||||||
<Table.Tr
|
|
||||||
key={app.id}
|
|
||||||
onClick={() => setSelectedId(app.id)}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isSelected
|
|
||||||
? 'var(--mantine-color-emaPrimary-light)'
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={600} fz="sm">
|
|
||||||
{app.referenceNo}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{licenseType(app.licenseType)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{formatDate(app.submittedAt)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" fw={500}>
|
|
||||||
{etb(SERVICE_FEE[app.licenseType])}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<StatusPill status={app.status} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
</Grid.Col>
|
|
||||||
|
|
||||||
{/* ---- Status timeline ------------------------------------- */}
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
|
||||||
{selected && <TimelinePanel application={selected} />}
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelinePanel({ application }: { application: Application }) {
|
|
||||||
const { licenseType, formatDate } = useLicenseLabels();
|
|
||||||
const current = stageIndex(application.status);
|
|
||||||
const historyDates = new Map(
|
|
||||||
application.history.map((h) => [h.status, h.date] as const),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Group justify="space-between" align="flex-start" mb="sm">
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>{application.referenceNo}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{licenseType(application.licenseType)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={application.status} />
|
|
||||||
</Group>
|
|
||||||
<Divider mb="lg" />
|
|
||||||
|
|
||||||
<Timeline active={current} bulletSize={26} lineWidth={2} color="emaPrimary">
|
|
||||||
{APPLICATION_PIPELINE.map((status, i) => {
|
|
||||||
const done = i < current;
|
|
||||||
const isCurrent = i === current;
|
|
||||||
const date = historyDates.get(status);
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={status}
|
|
||||||
bullet={done ? <IconCheck size={14} /> : undefined}
|
|
||||||
title={
|
|
||||||
<Text fw={isCurrent || done ? 600 : 500} fz="sm">
|
|
||||||
{PIPELINE_LABELS[status]}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
lineVariant={done ? 'solid' : 'dashed'}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
fz="xs"
|
|
||||||
c={isCurrent ? 'emaPrimary' : 'dimmed'}
|
|
||||||
fw={isCurrent ? 600 : 400}
|
|
||||||
>
|
|
||||||
{date
|
|
||||||
? formatDate(date)
|
|
||||||
: isCurrent
|
|
||||||
? 'In progress'
|
|
||||||
: 'Pending'}
|
|
||||||
</Text>
|
|
||||||
</Timeline.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
|
|
||||||
<Group
|
|
||||||
gap="sm"
|
|
||||||
mt="lg"
|
|
||||||
p="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
background: 'var(--mantine-color-indigo-light)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon variant="transparent" color="indigo" size="sm">
|
|
||||||
<IconClock size={17} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="sm" fw={500} c="indigo">
|
|
||||||
Last updated {formatDate(application.updatedAt)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="sm">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<ActionRow
|
|
||||||
icon={<IconDownload size={18} />}
|
|
||||||
label="Download submission receipt"
|
|
||||||
onClick={() => notify.info('Receipt download — coming soon.')}
|
|
||||||
/>
|
|
||||||
<ActionRow
|
|
||||||
icon={<IconMessageCircle size={18} />}
|
|
||||||
label="Message case officer"
|
|
||||||
onClick={() => notify.info('Messaging — coming soon.')}
|
|
||||||
/>
|
|
||||||
<ActionRow
|
|
||||||
icon={<IconCircleX size={18} />}
|
|
||||||
label="Withdraw application"
|
|
||||||
danger
|
|
||||||
onClick={() => notify.info('Withdrawal — coming soon.')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
danger,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<UnstyledButton onClick={onClick} p="xs" style={{ borderRadius: 'var(--mantine-radius-sm)' }}>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<Box c={danger ? 'red' : 'dimmed'} style={{ display: 'flex' }}>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
<Text fz="sm" fw={500} flex={1} c={danger ? 'red' : undefined}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<IconChevronRight size={15} style={{ opacity: 0.4 }} />
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Checkbox,
|
|
||||||
Divider,
|
|
||||||
Grid,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Stepper,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconArrowLeft,
|
|
||||||
IconArrowRight,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconCloudUpload,
|
|
||||||
IconDeviceMobile,
|
|
||||||
IconId,
|
|
||||||
IconInfoCircle,
|
|
||||||
IconLifebuoy,
|
|
||||||
IconMapPin,
|
|
||||||
IconMessageCircle,
|
|
||||||
IconShip,
|
|
||||||
IconStack2,
|
|
||||||
IconUser,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { useLicenses } from '../../features/licenses/hooks/useLicenses';
|
|
||||||
import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels';
|
|
||||||
import {
|
|
||||||
LICENSE_PROCESSING_DAYS,
|
|
||||||
LICENSE_TYPE_LABELS,
|
|
||||||
LICENSE_VALIDITY_YEARS,
|
|
||||||
REQUEST_TYPE_LABELS,
|
|
||||||
REQUIRED_DOCUMENTS,
|
|
||||||
} from '../../features/licenses/constants';
|
|
||||||
import type { LicenseType, RequestType } from '../../features/licenses/types/license.types';
|
|
||||||
|
|
||||||
const SERVICE_FEE: Record<LicenseType, number> = {
|
|
||||||
SEAFARER_COC: 1200,
|
|
||||||
SEAFARER_COP: 900,
|
|
||||||
SEAMAN_BOOK: 600,
|
|
||||||
VESSEL_REGISTRATION: 3500,
|
|
||||||
SHIP_RADIO: 900,
|
|
||||||
TONNAGE_CERTIFICATE: 2400,
|
|
||||||
SAFETY_MANAGEMENT: 5000,
|
|
||||||
PORT_FACILITY: 5000,
|
|
||||||
BOAT_OPERATOR: 700,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other'];
|
|
||||||
const REGIONS = [
|
|
||||||
'Addis Ababa',
|
|
||||||
'Dire Dawa',
|
|
||||||
'Amhara',
|
|
||||||
'Oromia',
|
|
||||||
'Tigray',
|
|
||||||
'Afar',
|
|
||||||
'Somali',
|
|
||||||
'Sidama',
|
|
||||||
'South Ethiopia',
|
|
||||||
'Gambela',
|
|
||||||
'Benishangul-Gumuz',
|
|
||||||
'Harari',
|
|
||||||
];
|
|
||||||
|
|
||||||
const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: LICENSE_TYPE_LABELS[v],
|
|
||||||
}));
|
|
||||||
const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: REQUEST_TYPE_LABELS[v],
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function ApplyV2() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { submit } = useLicenses();
|
|
||||||
const { subjectLabel, doc } = useLicenseLabels();
|
|
||||||
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [licenseType, setLicenseType] = useState<LicenseType | null>('SEAFARER_COC');
|
|
||||||
const [category, setCategory] = useState<string | null>('Deck Officer');
|
|
||||||
const [region, setRegion] = useState<string | null>('Addis Ababa');
|
|
||||||
const [requestType, setRequestType] = useState<RequestType>('NEW');
|
|
||||||
const [fullName, setFullName] = useState('');
|
|
||||||
const [idNumber, setIdNumber] = useState('');
|
|
||||||
const [phone, setPhone] = useState('');
|
|
||||||
const [subjectName, setSubjectName] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [uploaded, setUploaded] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const requiredDocs = useMemo(
|
|
||||||
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
|
||||||
[licenseType],
|
|
||||||
);
|
|
||||||
const attached = requiredDocs.filter((d) => uploaded[d]).length;
|
|
||||||
|
|
||||||
const next = () => setActive((c) => Math.min(c + 1, 3));
|
|
||||||
const prev = () => setActive((c) => Math.max(c - 1, 0));
|
|
||||||
|
|
||||||
const canContinue = () => {
|
|
||||||
if (active === 0) return !!licenseType && !!fullName.trim();
|
|
||||||
if (active === 1) return !!subjectName.trim();
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!licenseType) return;
|
|
||||||
submit({
|
|
||||||
requestType,
|
|
||||||
licenseType,
|
|
||||||
applicantName: fullName.trim(),
|
|
||||||
subjectName: subjectName.trim() || fullName.trim(),
|
|
||||||
notes: notes.trim() || undefined,
|
|
||||||
documents: requiredDocs.filter((d) => uploaded[d]),
|
|
||||||
});
|
|
||||||
notify.success('Your application has been submitted to EMA.');
|
|
||||||
navigate('/v2/applications');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Stepper active={active} onStepClick={setActive} size="sm" iconSize={34}>
|
|
||||||
<Stepper.Step label="Service & Applicant" description="Step 1" />
|
|
||||||
<Stepper.Step label="License Details" description="Step 2" />
|
|
||||||
<Stepper.Step label="Documents" description="Step 3" />
|
|
||||||
<Stepper.Step label="Review & Submit" description="Step 4" />
|
|
||||||
</Stepper>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Grid gutter="lg" align="start">
|
|
||||||
{/* ---- Form card ------------------------------------------- */}
|
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
||||||
<Paper withBorder radius="lg" p="xl">
|
|
||||||
{active === 0 && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<SectionHead
|
|
||||||
title="Service & Applicant Details"
|
|
||||||
subtitle="Choose the license you need and tell us who the application is for."
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="License type"
|
|
||||||
leftSection={<IconShip size={18} />}
|
|
||||||
data={LICENSE_OPTIONS}
|
|
||||||
value={licenseType}
|
|
||||||
onChange={(v) => setLicenseType(v as LicenseType)}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
<Select
|
|
||||||
label="Category"
|
|
||||||
leftSection={<IconStack2 size={18} />}
|
|
||||||
data={CATEGORIES}
|
|
||||||
value={category}
|
|
||||||
onChange={setCategory}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Region"
|
|
||||||
leftSection={<IconMapPin size={18} />}
|
|
||||||
data={REGIONS}
|
|
||||||
value={region}
|
|
||||||
onChange={setRegion}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<TextInput
|
|
||||||
label="Full name (as on ID)"
|
|
||||||
leftSection={<IconUser size={18} />}
|
|
||||||
placeholder="Abebe Bekele Tadesse"
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => setFullName(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="National ID / Passport"
|
|
||||||
leftSection={<IconId size={18} />}
|
|
||||||
placeholder="ET-1234567"
|
|
||||||
value={idNumber}
|
|
||||||
onChange={(e) => setIdNumber(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Phone number"
|
|
||||||
leftSection={<IconDeviceMobile size={18} />}
|
|
||||||
placeholder="+251 911 234 567"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<Dropzone />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 1 && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<SectionHead
|
|
||||||
title="License Details"
|
|
||||||
subtitle="A few more details about this specific request."
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Request type"
|
|
||||||
data={REQUEST_OPTIONS}
|
|
||||||
value={requestType}
|
|
||||||
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
|
||||||
allowDeselect={false}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
|
||||||
placeholder={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
|
||||||
value={subjectName}
|
|
||||||
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label="Additional notes (optional)"
|
|
||||||
placeholder="Anything the reviewing officer should know"
|
|
||||||
autosize
|
|
||||||
minRows={3}
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 2 && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<SectionHead
|
|
||||||
title="Required Documents"
|
|
||||||
subtitle="Tick each document once you have attached it."
|
|
||||||
/>
|
|
||||||
<Alert variant="light" color="emaPrimary" icon={<IconInfoCircle size={18} />}>
|
|
||||||
All required documents must be provided before the review can be completed.
|
|
||||||
</Alert>
|
|
||||||
{requiredDocs.map((d) => (
|
|
||||||
<Checkbox
|
|
||||||
key={d}
|
|
||||||
label={doc(d)}
|
|
||||||
checked={!!uploaded[d]}
|
|
||||||
onChange={() =>
|
|
||||||
setUploaded((s) => ({ ...s, [d]: !s[d] }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 3 && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<SectionHead
|
|
||||||
title="Review & Submit"
|
|
||||||
subtitle="Confirm the details below, then submit to EMA."
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
<ReviewItem label="License type" value={licenseType ? LICENSE_TYPE_LABELS[licenseType] : '—'} />
|
|
||||||
<ReviewItem label="Request type" value={REQUEST_TYPE_LABELS[requestType]} />
|
|
||||||
<ReviewItem label="Full name" value={fullName || '—'} />
|
|
||||||
<ReviewItem label="Region" value={region ?? '—'} />
|
|
||||||
<ReviewItem label="Subject" value={subjectName || '—'} />
|
|
||||||
<ReviewItem
|
|
||||||
label="Documents attached"
|
|
||||||
value={`${attached} of ${requiredDocs.length}`}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
{attached < requiredDocs.length && (
|
|
||||||
<Alert variant="light" color="orange">
|
|
||||||
Some required documents are still missing. You can still submit, but
|
|
||||||
review may be delayed.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" mt="xl">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconArrowLeft size={18} />}
|
|
||||||
onClick={prev}
|
|
||||||
disabled={active === 0}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
{active < 3 ? (
|
|
||||||
<Button
|
|
||||||
rightSection={<IconArrowRight size={18} />}
|
|
||||||
onClick={next}
|
|
||||||
disabled={!canContinue()}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
color="teal"
|
|
||||||
leftSection={<IconCircleCheck size={18} />}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Submit application
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
</Grid.Col>
|
|
||||||
|
|
||||||
{/* ---- Summary + help -------------------------------------- */}
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Application Summary
|
|
||||||
</Title>
|
|
||||||
<Group
|
|
||||||
gap="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
p="sm"
|
|
||||||
mb="md"
|
|
||||||
style={{
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
background: 'var(--mantine-color-emaPrimary-light)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeIcon variant="filled" color="emaPrimary" size={40} radius="md">
|
|
||||||
<IconShip size={21} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fw={700} fz="sm" truncate>
|
|
||||||
{licenseType ? LICENSE_TYPE_LABELS[licenseType] : 'Select a license'}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
{category ?? '—'} · {REQUEST_TYPE_LABELS[requestType]}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<SummaryRow
|
|
||||||
label="Processing time"
|
|
||||||
value={
|
|
||||||
licenseType
|
|
||||||
? `${LICENSE_PROCESSING_DAYS[licenseType][0]}–${LICENSE_PROCESSING_DAYS[licenseType][1]} working days`
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="License validity"
|
|
||||||
value={
|
|
||||||
licenseType ? `${LICENSE_VALIDITY_YEARS[licenseType]} years` : '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Service fee"
|
|
||||||
value={licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Divider my="md" />
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text fw={700}>Total payable</Text>
|
|
||||||
<Text fw={700} fz="lg" c="emaPrimary">
|
|
||||||
{licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper radius="lg" p="lg" bg="var(--mantine-color-emaTeal-light)">
|
|
||||||
<Group gap="sm" mb="xs">
|
|
||||||
<ThemeIcon variant="filled" color="emaTeal.8" size={36} radius="md">
|
|
||||||
<IconLifebuoy size={19} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700} c="emaTeal.8">
|
|
||||||
Need help?
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fz="sm" c="dimmed" mb="md" lh={1.55}>
|
|
||||||
Our support team can guide you through the documents required for this
|
|
||||||
license.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="white"
|
|
||||||
color="emaTeal.8"
|
|
||||||
leftSection={<IconMessageCircle size={16} />}
|
|
||||||
onClick={() => navigate('/support')}
|
|
||||||
>
|
|
||||||
Contact support
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionHead({ title, subtitle }: { title: string; subtitle: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Title order={4}>{title}</Title>
|
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text fz="sm" fw={600} ta="right">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" mt={2}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dropzone() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={600} c="dimmed" mb={6}>
|
|
||||||
Supporting document
|
|
||||||
</Text>
|
|
||||||
<Center
|
|
||||||
p="xl"
|
|
||||||
style={{
|
|
||||||
border: '1.5px dashed var(--mantine-color-default-border)',
|
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
|
||||||
background: 'var(--mantine-color-default-hover)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={() => notify.info('File upload — coming soon.')}
|
|
||||||
>
|
|
||||||
<Stack gap={8} align="center">
|
|
||||||
<ThemeIcon variant="light" color="emaPrimary" size={46} radius="xl">
|
|
||||||
<IconCloudUpload size={23} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="sm" fw={600}>
|
|
||||||
Drag & drop files here, or click to browse
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed">
|
|
||||||
PDF, JPG or PNG — up to 10 MB
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Center,
|
|
||||||
Grid,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
UnstyledButton,
|
|
||||||
useMantineTheme,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconArrowRight,
|
|
||||||
IconAward,
|
|
||||||
IconChevronRight,
|
|
||||||
IconClockHour4,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconFileText,
|
|
||||||
IconLifebuoy,
|
|
||||||
IconShip,
|
|
||||||
IconSquareRoundedPlus,
|
|
||||||
IconUpload,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import type { Icon } from '@tabler/icons-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { useLicenses } from '../../features/licenses/hooks/useLicenses';
|
|
||||||
import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels';
|
|
||||||
import { StatusDonut } from '../../features/dashboard/components/StatusDonut';
|
|
||||||
import type { ApplicationStatus } from '../../features/licenses/types/license.types';
|
|
||||||
import { StatusPill } from '../components/ui';
|
|
||||||
|
|
||||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
|
||||||
'SUBMITTED',
|
|
||||||
'UNDER_REVIEW',
|
|
||||||
'INFO_REQUESTED',
|
|
||||||
'PAYMENT_PENDING',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DashboardV2() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
const { applications, licenses } = useLicenses();
|
|
||||||
const { licenseType, formatDate } = useLicenseLabels();
|
|
||||||
|
|
||||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
|
||||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
|
||||||
const approved = applications.filter(
|
|
||||||
(a) => a.status === 'APPROVED' || a.status === 'ISSUED',
|
|
||||||
).length;
|
|
||||||
const documents = applications.reduce((sum, a) => sum + a.documents.length, 0);
|
|
||||||
|
|
||||||
const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0];
|
|
||||||
const recent = applications.slice(0, 6);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* ---- Hero banner ------------------------------------------- */}
|
|
||||||
<Paper
|
|
||||||
radius="lg"
|
|
||||||
p="xl"
|
|
||||||
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap" align="center">
|
|
||||||
<Stack gap="md" maw={560}>
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Title order={2} c="white" fz={26}>
|
|
||||||
Welcome back, {firstName} 👋
|
|
||||||
</Title>
|
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
|
||||||
You have {pending} applications in review and {activeLicenses} active
|
|
||||||
licence{activeLicenses === 1 ? '' : 's'}. Start a new application or check
|
|
||||||
your status below.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Button
|
|
||||||
w="fit-content"
|
|
||||||
color="white"
|
|
||||||
c="emaPrimary.7"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<IconSquareRoundedPlus size={18} />}
|
|
||||||
onClick={() => navigate('/v2/apply')}
|
|
||||||
>
|
|
||||||
Apply for a license
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Center
|
|
||||||
visibleFrom="sm"
|
|
||||||
w={120}
|
|
||||||
h={120}
|
|
||||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.15)', flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<IconShip size={62} color="white" stroke={1.4} />
|
|
||||||
</Center>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* ---- Stat cards -------------------------------------------- */}
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2, lg: 4 }} spacing="lg">
|
|
||||||
<StatCard
|
|
||||||
icon={IconAward}
|
|
||||||
color="teal"
|
|
||||||
value={activeLicenses}
|
|
||||||
label="Active Licenses"
|
|
||||||
trend="+1 this year"
|
|
||||||
trendColor="teal"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={IconClockHour4}
|
|
||||||
color="indigo"
|
|
||||||
value={pending}
|
|
||||||
label="Pending Applications"
|
|
||||||
trend="In review"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={IconCircleCheck}
|
|
||||||
color="teal"
|
|
||||||
value={approved}
|
|
||||||
label="Approved"
|
|
||||||
trend="100% pass"
|
|
||||||
trendColor="teal"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
icon={IconFileText}
|
|
||||||
color="emaPrimary"
|
|
||||||
value={documents}
|
|
||||||
label="Documents"
|
|
||||||
trend="Up to date"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* ---- Lower row --------------------------------------------- */}
|
|
||||||
<Grid gutter="lg">
|
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
||||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Title order={4}>Recent Applications</Title>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-sm"
|
|
||||||
rightSection={<IconArrowRight size={15} />}
|
|
||||||
onClick={() => navigate('/v2/applications')}
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table verticalSpacing="sm" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Application</Table.Th>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
<Table.Th>Submitted</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th w={40} />
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{recent.map((app) => (
|
|
||||||
<Table.Tr
|
|
||||||
key={app.id}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => navigate('/v2/applications')}
|
|
||||||
>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={600} fz="sm">
|
|
||||||
{app.referenceNo}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{licenseType(app.licenseType)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{formatDate(app.submittedAt)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<StatusPill status={app.status} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
</Grid.Col>
|
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Application Status
|
|
||||||
</Title>
|
|
||||||
<StatusDonut applications={applications} />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Title order={4} mb="md">
|
|
||||||
Quick actions
|
|
||||||
</Title>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<QuickAction
|
|
||||||
icon={IconSquareRoundedPlus}
|
|
||||||
color="emaPrimary"
|
|
||||||
label="Apply for new license"
|
|
||||||
onClick={() => navigate('/v2/apply')}
|
|
||||||
/>
|
|
||||||
<QuickAction
|
|
||||||
icon={IconUpload}
|
|
||||||
color="teal"
|
|
||||||
label="Upload a document"
|
|
||||||
onClick={() => notify.info('Document upload — coming soon.')}
|
|
||||||
/>
|
|
||||||
<QuickAction
|
|
||||||
icon={IconLifebuoy}
|
|
||||||
color="orange"
|
|
||||||
label="Contact support"
|
|
||||||
onClick={() => navigate('/support')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
icon: CardIcon,
|
|
||||||
color,
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
trend,
|
|
||||||
trendColor = 'gray',
|
|
||||||
}: {
|
|
||||||
icon: Icon;
|
|
||||||
color: string;
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
trend: string;
|
|
||||||
trendColor?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Paper withBorder radius="lg" p="lg" className="ema-hover-lift">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<ThemeIcon variant="light" color={color} size={42} radius="md">
|
|
||||||
<CardIcon size={22} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="xs" fw={600} c={trendColor === 'gray' ? 'dimmed' : trendColor}>
|
|
||||||
{trend}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fz={30} fw={700} lh={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickAction({
|
|
||||||
icon: ActionIconCmp,
|
|
||||||
color,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
icon: Icon;
|
|
||||||
color: string;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<UnstyledButton onClick={onClick}>
|
|
||||||
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color={color} size={38} radius="md">
|
|
||||||
<ActionIconCmp size={19} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="sm" fw={500} flex={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user