update the protal dashboard

This commit is contained in:
Mengisteab
2026-06-15 12:40:26 +00:00
parent 7e6e9eb1a7
commit fbcd68bb5b
11 changed files with 1175 additions and 1864 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: (

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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>
);
}