mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'feature/pencil-design' of github.com:Tria-plc/emaui into feature/pencil-design
This commit is contained in:
@@ -23,7 +23,11 @@
|
||||
"Bash(git log *)",
|
||||
"Bash(grep -rl '_$_1e42\\\\|sfL\\(' --exclude-dir=node_modules --exclude-dir=.git .)",
|
||||
"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\\)\\)\")",
|
||||
"Bash(node_modules/.bin/tsc --project apps/portal/tsconfig.app.json --noEmit)",
|
||||
"Bash(node_modules/.bin/tsc --project apps/portal/tsconfig.json --noEmit)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/home/tria/projects/mengisteab/emaui"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Badge, Box, useMantineTheme } from '@mantine/core';
|
||||
import { APPLICATION_STATUS_COLORS } from '../../features/licenses/constants';
|
||||
import type { ApplicationStatus } from '../../features/licenses/types/license.types';
|
||||
import { useLicenseLabels } from '../../features/licenses/hooks/useLicenseLabels';
|
||||
import { APPLICATION_STATUS_COLORS } from '../features/licenses/constants';
|
||||
import type { ApplicationStatus } from '../features/licenses/types/license.types';
|
||||
import { useLicenseLabels } from '../features/licenses/hooks/useLicenseLabels';
|
||||
|
||||
/** Pill status badge with a colored dot — matches the design system. */
|
||||
export function StatusPill({ status }: { status: ApplicationStatus }) {
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
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({
|
||||
initials = 'AB',
|
||||
size = 38,
|
||||
@@ -1,36 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCertificate,
|
||||
IconClockHour4,
|
||||
IconFilePlus,
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '../../licenses/components/StatCard';
|
||||
import { ApplicationsTable } from '../../licenses/components/ApplicationsTable';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
||||
import { DashboardHero } from '../components/DashboardHero';
|
||||
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||
import { StatusDonut } from '../components/StatusDonut';
|
||||
import { ApplicationsTrend } from '../components/ApplicationsTrend';
|
||||
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
||||
import { StatusPill } from '../../../components/ui';
|
||||
|
||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
@@ -40,159 +44,260 @@ const OPEN_STATUSES: ApplicationStatus[] = [
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useMantineTheme();
|
||||
const { applications, licenses } = useLicenses();
|
||||
|
||||
// Brief skeleton on the charts for a polished first paint.
|
||||
const [chartsLoading, setChartsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setChartsLoading(false), 550);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
const { licenseType, formatDate } = useLicenseLabels();
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
||||
const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length;
|
||||
const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length;
|
||||
const approved = applications.filter(
|
||||
(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 holderName = licenses[0]?.holderName;
|
||||
|
||||
const quickActions: {
|
||||
icon: Icon;
|
||||
title: string;
|
||||
desc: string;
|
||||
to: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
icon: IconFilePlus,
|
||||
title: t('dashboard.quick.apply'),
|
||||
desc: t('dashboard.quick.applyDesc'),
|
||||
to: '/apply',
|
||||
color: 'emaPrimary',
|
||||
},
|
||||
{
|
||||
icon: IconRefresh,
|
||||
title: t('dashboard.quick.renew'),
|
||||
desc: t('dashboard.quick.renewDesc'),
|
||||
to: '/licenses',
|
||||
color: 'emaTeal',
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
title: t('dashboard.quick.track'),
|
||||
desc: t('dashboard.quick.trackDesc'),
|
||||
to: '/applications',
|
||||
color: 'indigo',
|
||||
},
|
||||
];
|
||||
const firstName = (licenses[0]?.holderName ?? 'there').split(' ')[0];
|
||||
const recent = applications.slice(0, 6);
|
||||
|
||||
return (
|
||||
<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
|
||||
label={t('dashboard.stats.activeLicenses')}
|
||||
value={activeLicenses}
|
||||
icon={IconCertificate}
|
||||
icon={IconAward}
|
||||
color="teal"
|
||||
value={activeLicenses}
|
||||
label="Active Licenses"
|
||||
trend="+1 this year"
|
||||
trendColor="teal"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.pendingApplications')}
|
||||
value={pending}
|
||||
icon={IconClockHour4}
|
||||
color="indigo"
|
||||
value={pending}
|
||||
label="Pending Applications"
|
||||
trend="In review"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.expiringSoon')}
|
||||
value={expiring}
|
||||
icon={IconAlertTriangle}
|
||||
color="orange"
|
||||
icon={IconCircleCheck}
|
||||
color="teal"
|
||||
value={approved}
|
||||
label="Approved"
|
||||
trend="100% pass"
|
||||
trendColor="teal"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.actionRequired')}
|
||||
value={actionRequired}
|
||||
icon={IconAlertTriangle}
|
||||
color="red"
|
||||
icon={IconFileText}
|
||||
color="emaPrimary"
|
||||
value={documents}
|
||||
label="Documents"
|
||||
trend="Up to date"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
|
||||
{quickActions.map((qa) => (
|
||||
<UnstyledButton key={qa.to} onClick={() => navigate(qa.to)}>
|
||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={qa.color} size={42}>
|
||||
<qa.icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{qa.title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{qa.desc}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<IconArrowRight size={18} style={{ opacity: 0.5 }} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{t('dashboard.charts.trend')}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.trendPeriod')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{chartsLoading ? (
|
||||
<Skeleton height={240} radius="md" />
|
||||
) : (
|
||||
<ApplicationsTrend applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
{t('dashboard.charts.distribution')}
|
||||
</Title>
|
||||
{chartsLoading ? (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Skeleton circle height={180} width={180} />
|
||||
<Stack gap="sm" flex={1}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={14} radius="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
{/* ---- Lower row --------------------------------------------- */}
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Recent Applications</Title>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<StatusDonut applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{t('dashboard.recentApplications')}</Title>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Button>
|
||||
</Group>
|
||||
<ApplicationsTable applications={recent} />
|
||||
</Paper>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={40} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{recent.map((app) => (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz="sm">
|
||||
{app.referenceNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{licenseType(app.licenseType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{formatDate(app.submittedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Application Status
|
||||
</Title>
|
||||
<StatusDonut applications={applications} />
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Quick actions
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
<QuickAction
|
||||
icon={IconSquareRoundedPlus}
|
||||
color="emaPrimary"
|
||||
label="Apply for new license"
|
||||
onClick={() => navigate('/apply')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconUpload}
|
||||
color="teal"
|
||||
label="Upload a document"
|
||||
onClick={() => notify.info('Document upload — coming soon.')}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={IconLifebuoy}
|
||||
color="orange"
|
||||
label="Contact support"
|
||||
onClick={() => navigate('/support')}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,349 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Group, Paper, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { IconFilePlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { ApplicationsTable } from '../components/ApplicationsTable';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAdjustmentsHorizontal,
|
||||
IconCheck,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconCircleX,
|
||||
IconDownload,
|
||||
IconMessageCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import { APPLICATION_STATUS_LABELS } from '../constants';
|
||||
import type { ApplicationStatus } from '../types/license.types';
|
||||
import { APPLICATION_PIPELINE } from '../constants';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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 [status, setStatus] = useState<string | null>(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(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return applications.filter((app) => {
|
||||
const matchesStatus = !status || app.status === status;
|
||||
const matchesQuery =
|
||||
!q ||
|
||||
app.referenceNo.toLowerCase().includes(q) ||
|
||||
app.subjectName.toLowerCase().includes(q) ||
|
||||
app.applicantName.toLowerCase().includes(q);
|
||||
return matchesStatus && matchesQuery;
|
||||
});
|
||||
}, [applications, query, status]);
|
||||
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 statusOptions = [
|
||||
{ value: '', label: t('common.all') },
|
||||
...STATUS_VALUES.map((s) => ({ value: s, label: applicationStatus(s) })),
|
||||
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">
|
||||
<PageHeader
|
||||
title={t('applications.title')}
|
||||
subtitle={t('applications.subtitle')}
|
||||
action={
|
||||
<Button
|
||||
leftSection={<IconFilePlus size={18} />}
|
||||
onClick={() => navigate('/apply')}
|
||||
>
|
||||
{t('dashboard.newApplication')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw={220}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t('applications.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
w={220}
|
||||
placeholder={t('common.status')}
|
||||
data={statusOptions}
|
||||
value={status ?? ''}
|
||||
onChange={(v) => setStatus(v || null)}
|
||||
clearable={false}
|
||||
/>
|
||||
</Group>
|
||||
<Grid gutter="lg" align="start">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="xs">
|
||||
<Table verticalSpacing="md" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Fee</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={36} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => {
|
||||
const isSelected = selected?.id === app.id;
|
||||
return (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
onClick={() => setSelectedId(app.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--mantine-color-emaPrimary-light)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} fz="sm">
|
||||
{app.referenceNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{licenseType(app.licenseType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{formatDate(app.submittedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{etb(SERVICE_FEE[app.licenseType])}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={app.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<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>
|
||||
</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,8 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
@@ -12,40 +16,87 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { REQUIRED_DOCUMENTS } from '../constants';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { useLicenses } from '../hooks/useLicenses';
|
||||
import { useLicenseLabels } from '../hooks/useLicenseLabels';
|
||||
import {
|
||||
LICENSE_PROCESSING_DAYS,
|
||||
LICENSE_TYPE_LABELS,
|
||||
LICENSE_VALIDITY_YEARS,
|
||||
REQUEST_TYPE_LABELS,
|
||||
REQUIRED_DOCUMENTS,
|
||||
} from '../constants';
|
||||
import type { LicenseType, RequestType } from '../types/license.types';
|
||||
import { LICENSE_TYPE_LABELS } from '../constants';
|
||||
|
||||
const APPLY_REQUEST_TYPES: RequestType[] = ['NEW', 'RENEWAL'];
|
||||
const LICENSE_TYPE_VALUES = Object.keys(LICENSE_TYPE_LABELS) as LicenseType[];
|
||||
const SERVICE_FEE: Record<LicenseType, number> = {
|
||||
SEAFARER_COC: 1200,
|
||||
SEAFARER_COP: 900,
|
||||
SEAMAN_BOOK: 600,
|
||||
VESSEL_REGISTRATION: 3500,
|
||||
SHIP_RADIO: 900,
|
||||
TONNAGE_CERTIFICATE: 2400,
|
||||
SAFETY_MANAGEMENT: 5000,
|
||||
PORT_FACILITY: 5000,
|
||||
BOAT_OPERATOR: 700,
|
||||
};
|
||||
|
||||
const CATEGORIES = ['Deck Officer', 'Engine Officer', 'Ratings', 'Electro-technical', 'Other'];
|
||||
const REGIONS = [
|
||||
'Addis Ababa',
|
||||
'Dire Dawa',
|
||||
'Amhara',
|
||||
'Oromia',
|
||||
'Tigray',
|
||||
'Afar',
|
||||
'Somali',
|
||||
'Sidama',
|
||||
'South Ethiopia',
|
||||
'Gambela',
|
||||
'Benishangul-Gumuz',
|
||||
'Harari',
|
||||
];
|
||||
|
||||
const LICENSE_OPTIONS = (Object.keys(LICENSE_TYPE_LABELS) as LicenseType[]).map((v) => ({
|
||||
value: v,
|
||||
label: LICENSE_TYPE_LABELS[v],
|
||||
}));
|
||||
const REQUEST_OPTIONS = (['NEW', 'RENEWAL'] as RequestType[]).map((v) => ({
|
||||
value: v,
|
||||
label: REQUEST_TYPE_LABELS[v],
|
||||
}));
|
||||
|
||||
export function ApplyPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { submit } = useLicenses();
|
||||
const { licenseType: ltLabel, subjectLabel, requestType: rtLabel, doc } =
|
||||
useLicenseLabels();
|
||||
|
||||
const presetType = params.get('type') as LicenseType | null;
|
||||
const presetRequest = params.get('request') as RequestType | null;
|
||||
const { subjectLabel, doc } = useLicenseLabels();
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [requestType, setRequestType] = useState<RequestType>(
|
||||
presetRequest && APPLY_REQUEST_TYPES.includes(presetRequest) ? presetRequest : 'NEW',
|
||||
);
|
||||
const [licenseType, setLicenseType] = useState<LicenseType | null>(
|
||||
presetType && LICENSE_TYPE_VALUES.includes(presetType) ? presetType : null,
|
||||
);
|
||||
const [applicantName, setApplicantName] = useState('');
|
||||
const [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>>({});
|
||||
@@ -54,190 +105,323 @@ export function ApplyPage() {
|
||||
() => (licenseType ? REQUIRED_DOCUMENTS[licenseType] : []),
|
||||
[licenseType],
|
||||
);
|
||||
|
||||
const subjectFieldLabel = licenseType
|
||||
? subjectLabel(licenseType)
|
||||
: t('apply.fields.applicantName');
|
||||
|
||||
const canContinue = () => {
|
||||
if (active === 0) return !!licenseType;
|
||||
if (active === 1) return !!applicantName.trim() && !!subjectName.trim();
|
||||
return true;
|
||||
};
|
||||
|
||||
const allDocsUploaded =
|
||||
requiredDocs.length > 0 && requiredDocs.every((d) => uploaded[d]);
|
||||
const 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 toggleDoc = (name: string) =>
|
||||
setUploaded((prevState) => ({ ...prevState, [name]: !prevState[name] }));
|
||||
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: applicantName.trim(),
|
||||
subjectName: subjectName.trim(),
|
||||
applicantName: fullName.trim(),
|
||||
subjectName: subjectName.trim() || fullName.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
documents: requiredDocs.filter((d) => uploaded[d]),
|
||||
});
|
||||
notify.success(t('apply.submitted'));
|
||||
notify.success('Your application has been submitted to EMA.');
|
||||
navigate('/applications');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={860} mx="auto">
|
||||
<PageHeader title={t('apply.title')} subtitle={t('apply.subtitle')} />
|
||||
|
||||
<Paper p="xl" shadow="sm" radius="md" withBorder>
|
||||
<Stepper active={active} onStepClick={setActive} size="sm">
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.license')}
|
||||
description={t('apply.steps.licenseDesc')}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Select
|
||||
label={t('apply.fields.requestType')}
|
||||
data={APPLY_REQUEST_TYPES.map((v) => ({
|
||||
value: v,
|
||||
label: rtLabel(v),
|
||||
}))}
|
||||
value={requestType}
|
||||
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t('apply.fields.licenseType')}
|
||||
placeholder={t('apply.fields.licenseTypePlaceholder')}
|
||||
data={LICENSE_TYPE_VALUES.map((v) => ({
|
||||
value: v,
|
||||
label: ltLabel(v),
|
||||
}))}
|
||||
value={licenseType}
|
||||
onChange={(v) => setLicenseType(v as LicenseType)}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.details')}
|
||||
description={t('apply.steps.detailsDesc')}
|
||||
>
|
||||
<Stack gap="md" mt="xl">
|
||||
<TextInput
|
||||
label={t('apply.fields.applicantName')}
|
||||
placeholder={t('apply.fields.applicantPlaceholder')}
|
||||
value={applicantName}
|
||||
onChange={(e) => setApplicantName(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label={subjectFieldLabel}
|
||||
placeholder={subjectFieldLabel}
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={`${t('apply.fields.notes')} (${t('common.optional')})`}
|
||||
placeholder={t('apply.fields.notesPlaceholder')}
|
||||
minRows={3}
|
||||
autosize
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label={t('apply.steps.documents')}
|
||||
description={t('apply.steps.documentsDesc')}
|
||||
>
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="emaPrimary"
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
{t('apply.docs.hint')}
|
||||
</Alert>
|
||||
{requiredDocs.map((d) => (
|
||||
<Checkbox
|
||||
key={d}
|
||||
label={doc(d)}
|
||||
checked={!!uploaded[d]}
|
||||
onChange={() => toggleDoc(d)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Completed>
|
||||
<Stack gap="md" mt="xl">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconCircleCheck size={18} />}
|
||||
title={t('apply.review.title')}
|
||||
>
|
||||
{t('apply.review.hint')}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<ReviewItem
|
||||
label={t('apply.fields.requestType')}
|
||||
value={rtLabel(requestType)}
|
||||
/>
|
||||
<ReviewItem
|
||||
label={t('apply.fields.licenseType')}
|
||||
value={licenseType ? ltLabel(licenseType) : '—'}
|
||||
/>
|
||||
<ReviewItem
|
||||
label={t('apply.fields.applicantName')}
|
||||
value={applicantName || '—'}
|
||||
/>
|
||||
<ReviewItem label={subjectFieldLabel} value={subjectName || '—'} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t('apply.docs.attached')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('apply.docs.attachedCount', {
|
||||
count: requiredDocs.filter((d) => uploaded[d]).length,
|
||||
total: requiredDocs.length,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
{!allDocsUploaded && (
|
||||
<Alert variant="light" color="orange">
|
||||
{t('apply.review.missingDocs')}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Completed>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<Grid gutter="lg" align="start">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="Service & Applicant Details"
|
||||
subtitle="Choose the license you need and tell us who the application is for."
|
||||
/>
|
||||
<Select
|
||||
label="License type"
|
||||
leftSection={<IconShip size={18} />}
|
||||
data={LICENSE_OPTIONS}
|
||||
value={licenseType}
|
||||
onChange={(v) => setLicenseType(v as LicenseType)}
|
||||
searchable
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
label="Category"
|
||||
leftSection={<IconStack2 size={18} />}
|
||||
data={CATEGORIES}
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<Select
|
||||
label="Region"
|
||||
leftSection={<IconMapPin size={18} />}
|
||||
data={REGIONS}
|
||||
value={region}
|
||||
onChange={setRegion}
|
||||
searchable
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Full name (as on ID)"
|
||||
leftSection={<IconUser size={18} />}
|
||||
placeholder="Abebe Bekele Tadesse"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="National ID / Passport"
|
||||
leftSection={<IconId size={18} />}
|
||||
placeholder="ET-1234567"
|
||||
value={idNumber}
|
||||
onChange={(e) => setIdNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
placeholder="+251 911 234 567"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Dropzone />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="License Details"
|
||||
subtitle="A few more details about this specific request."
|
||||
/>
|
||||
<Select
|
||||
label="Request type"
|
||||
data={REQUEST_OPTIONS}
|
||||
value={requestType}
|
||||
onChange={(v) => setRequestType((v as RequestType) ?? 'NEW')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<TextInput
|
||||
label={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||
placeholder={licenseType ? subjectLabel(licenseType) : 'Subject'}
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Additional notes (optional)"
|
||||
placeholder="Anything the reviewing officer should know"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="sm">
|
||||
<SectionHead
|
||||
title="Required Documents"
|
||||
subtitle="Tick each document once you have attached it."
|
||||
/>
|
||||
<Alert variant="light" color="emaPrimary" icon={<IconInfoCircle size={18} />}>
|
||||
All required documents must be provided before the review can be completed.
|
||||
</Alert>
|
||||
{requiredDocs.map((d) => (
|
||||
<Checkbox
|
||||
key={d}
|
||||
label={doc(d)}
|
||||
checked={!!uploaded[d]}
|
||||
onChange={() =>
|
||||
setUploaded((s) => ({ ...s, [d]: !s[d] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
title="Review & Submit"
|
||||
subtitle="Confirm the details below, then submit to EMA."
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<ReviewItem label="License type" value={licenseType ? LICENSE_TYPE_LABELS[licenseType] : '—'} />
|
||||
<ReviewItem label="Request type" value={REQUEST_TYPE_LABELS[requestType]} />
|
||||
<ReviewItem label="Full name" value={fullName || '—'} />
|
||||
<ReviewItem label="Region" value={region ?? '—'} />
|
||||
<ReviewItem label="Subject" value={subjectName || '—'} />
|
||||
<ReviewItem
|
||||
label="Documents attached"
|
||||
value={`${attached} of ${requiredDocs.length}`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{attached < requiredDocs.length && (
|
||||
<Alert variant="light" color="orange">
|
||||
Some required documents are still missing. You can still submit, but
|
||||
review may be delayed.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={prev}
|
||||
disabled={active === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{active < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
onClick={next}
|
||||
disabled={!canContinue()}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={18} />}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={4} mb="md">
|
||||
Application Summary
|
||||
</Title>
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
mb="md"
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
background: 'var(--mantine-color-emaPrimary-light)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon variant="filled" color="emaPrimary" size={40} radius="md">
|
||||
<IconShip size={21} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} fz="sm" truncate>
|
||||
{licenseType ? LICENSE_TYPE_LABELS[licenseType] : 'Select a license'}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{category ?? '—'} · {REQUEST_TYPE_LABELS[requestType]}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<SummaryRow
|
||||
label="Processing time"
|
||||
value={
|
||||
licenseType
|
||||
? `${LICENSE_PROCESSING_DAYS[licenseType][0]}–${LICENSE_PROCESSING_DAYS[licenseType][1]} working days`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="License validity"
|
||||
value={
|
||||
licenseType ? `${LICENSE_VALIDITY_YEARS[licenseType]} years` : '—'
|
||||
}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Service fee"
|
||||
value={licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700}>Total payable</Text>
|
||||
<Text fw={700} fz="lg" c="emaPrimary">
|
||||
{licenseType ? `ETB ${SERVICE_FEE[licenseType].toLocaleString()}` : '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper radius="lg" p="lg" bg="var(--mantine-color-emaTeal-light)">
|
||||
<Group gap="sm" mb="xs">
|
||||
<ThemeIcon variant="filled" color="emaTeal.8" size={36} radius="md">
|
||||
<IconLifebuoy size={19} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} c="emaTeal.8">
|
||||
Need help?
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" mb="md" lh={1.55}>
|
||||
Our support team can guide you through the documents required for this
|
||||
license.
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="white"
|
||||
color="emaTeal.8"
|
||||
leftSection={<IconMessageCircle size={16} />}
|
||||
onClick={() => navigate('/support')}
|
||||
>
|
||||
Contact support
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Title order={4}>{title}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm" fw={600} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -250,3 +434,35 @@ function ReviewItem({ label, value }: { label: string; value: string }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowLeft,
|
||||
IconBook,
|
||||
IconBriefcase,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconEdit,
|
||||
IconFileText,
|
||||
IconHeartbeat,
|
||||
IconHistory,
|
||||
IconLayoutDashboard,
|
||||
IconPlus,
|
||||
IconPrinter,
|
||||
IconShip,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import type { Seafarer } from './SeafarerRegistryPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended profile types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface TrainingRecord {
|
||||
id: string;
|
||||
course: string;
|
||||
institution: string;
|
||||
certNo: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
status: 'Approved' | 'Pending' | 'Expired';
|
||||
}
|
||||
|
||||
interface MedicalRecord {
|
||||
id: string;
|
||||
examType: string;
|
||||
issuedBy: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
result: 'Fit' | 'Unfit' | 'Conditional';
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
interface SeaServiceRecord {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
vesselType: string;
|
||||
rank: string;
|
||||
flag: string;
|
||||
from: string;
|
||||
to: string;
|
||||
engagementPort: string;
|
||||
}
|
||||
|
||||
interface CertificationRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
certNo: string;
|
||||
issuedBy: string;
|
||||
issueDate: string;
|
||||
expiry: string;
|
||||
type: string;
|
||||
status: 'Valid' | 'Expired' | 'Pending';
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
performedBy: string;
|
||||
date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SeafarerProfile extends Seafarer {
|
||||
dob: string;
|
||||
nationalId: string;
|
||||
passportNo: string;
|
||||
bookNumber: string;
|
||||
permanentAddress: string;
|
||||
training: TrainingRecord[];
|
||||
medical: MedicalRecord[];
|
||||
seaService: SeaServiceRecord[];
|
||||
certifications: CertificationRecord[];
|
||||
history: HistoryEntry[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API — replace bodies with real fetch calls
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchSeafarerProfile(id: string): Promise<SeafarerProfile> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return {
|
||||
id,
|
||||
seafarerId: 'SF-2024-0001',
|
||||
firstName: 'Abebe',
|
||||
lastName: 'Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 911 234 567',
|
||||
region: 'Addis Ababa',
|
||||
registeredAt: '2024-01-10',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
dob: '1988-03-15',
|
||||
nationalId: 'ET-1234567',
|
||||
passportNo: 'EP123456',
|
||||
bookNumber: 'SB-2024-0001',
|
||||
permanentAddress: 'Bole Sub-City, Woreda 03, House No. 456, Addis Ababa',
|
||||
training: [
|
||||
{ id: '1', course: 'Personal Survival Techniques', institution: 'Ethiopian Maritime Institute', certNo: 'PST-2023-0456', issueDate: '2023-01-10', expiry: '2028-01-14', status: 'Approved' },
|
||||
{ id: '2', course: 'Fire Prevention and Fire Fighting', institution: 'Djibouti Maritime Academy', certNo: 'FFF-2023-0789', issueDate: '2023-03-05', expiry: '2028-03-07', status: 'Approved' },
|
||||
{ id: '3', course: 'Elementary First Aid', institution: 'Ethiopian Maritime Institute', certNo: 'EFA-2023-0102', issueDate: '2023-01-10', expiry: '2028-01-10', status: 'Approved' },
|
||||
],
|
||||
medical: [
|
||||
{ id: '1', examType: 'STCW Medical Certificate', issuedBy: 'EMA Medical Center', issueDate: '2023-06-15', expiry: '2025-06-15', result: 'Fit', remarks: 'No medical conditions noted.' },
|
||||
{ id: '2', examType: 'Pre-Employment Medical', issuedBy: 'Addis Ababa General Hospital', issueDate: '2022-01-10', expiry: '2024-01-10', result: 'Fit', remarks: 'All tests within normal range.' },
|
||||
],
|
||||
seaService: [
|
||||
{ id: '1', vesselName: 'MV Ethiopian Star', vesselType: 'Bulk Carrier', rank: 'Ordinary Seaman', flag: 'Ethiopia', from: '2022-03-01', to: '2023-02-28', engagementPort: 'Djibouti' },
|
||||
{ id: '2', vesselName: 'MV Red Sea Express', vesselType: 'Container Ship', rank: 'Able Seaman', flag: 'Djibouti', from: '2023-04-01', to: '2024-03-31', engagementPort: 'Berbera' },
|
||||
],
|
||||
certifications: [
|
||||
{ id: '1', name: 'STCW Basic Safety Training', certNo: 'BST-2023-0001', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-01-15', expiry: '2028-01-15', type: 'STCW', status: 'Valid' },
|
||||
{ id: '2', name: 'Certificate of Competency — Deck Rating', certNo: 'COC-2023-0234', issuedBy: 'Ethiopian Maritime Authority', issueDate: '2023-07-01', expiry: '2028-07-01', type: 'COC', status: 'Valid' },
|
||||
],
|
||||
history: [
|
||||
{ id: '1', action: 'Profile Created', performedBy: 'System', date: '2024-01-10', notes: 'Initial registration submitted.' },
|
||||
{ id: '2', action: 'Status → Active', performedBy: 'Admin Officer', date: '2024-01-15', notes: 'All documents verified and approved.' },
|
||||
{ id: '3', action: 'Training Record Added', performedBy: 'Abebe Girma', date: '2024-02-20', notes: 'PST certificate uploaded.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function updateSeafarerStatus(_id: string, _status: string): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal', Pending: 'yellow', Suspended: 'red',
|
||||
Approved: 'teal', Expired: 'red', Valid: 'teal',
|
||||
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
|
||||
};
|
||||
|
||||
function Chip({ value }: { value: string }) {
|
||||
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">{title}</Text>
|
||||
{action}
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Overview
|
||||
// ---------------------------------------------------------------------------
|
||||
function OverviewTab({ profile, onStatusChange }: { profile: SeafarerProfile; onStatusChange: (s: 'Active' | 'Suspended') => void }) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<SectionCard title="Personal Information">
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
<InfoField label="Seafarer ID" value={profile.seafarerId} />
|
||||
<InfoField label="First Name" value={profile.firstName} />
|
||||
<InfoField label="Last Name" value={profile.lastName} />
|
||||
<InfoField label="Gender" value={profile.gender} />
|
||||
<InfoField label="Date of Birth" value={profile.dob} />
|
||||
<InfoField label="Nationality" value={profile.nationality} />
|
||||
<InfoField label="National ID" value={profile.nationalId} />
|
||||
<InfoField label="Passport No." value={profile.passportNo} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Contact & Status">
|
||||
<SimpleGrid cols={3} spacing="md" mb="md">
|
||||
<InfoField label="Mobile" value={profile.mobile} />
|
||||
<InfoField label="Email" value={profile.email} />
|
||||
<InfoField label="Region" value={profile.region} />
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Reg. Status</Text>
|
||||
<Chip value={profile.status} />
|
||||
</div>
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Medical Status</Text>
|
||||
<Chip value={profile.medicalStatus} />
|
||||
</div>
|
||||
<InfoField label="Book Number" value={profile.bookNumber} />
|
||||
<div>
|
||||
<Text fz={10} fw={700} tt="uppercase" c="dimmed" lh={1.2} mb={3}>Book Status</Text>
|
||||
<Chip value={profile.bookStatus} />
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<Group gap="xs">
|
||||
{profile.status !== 'Active' && (
|
||||
<Button size="xs" color="teal" leftSection={<IconCheck size={13} />} onClick={() => onStatusChange('Active')}>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{profile.status !== 'Suspended' && (
|
||||
<Button size="xs" color="red" variant="light" leftSection={<IconX size={13} />} onClick={() => onStatusChange('Suspended')}>
|
||||
Suspend
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" variant="default" leftSection={<IconFileText size={13} />} onClick={() => notify.info('Documents — coming soon.')}>
|
||||
Documents
|
||||
</Button>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="xs">Permanent Address</Text>
|
||||
<Text fz="sm" c="dimmed">{profile.permanentAddress || '—'}</Text>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Training
|
||||
// ---------------------------------------------------------------------------
|
||||
function TrainingTab({ records, onAdd }: { records: TrainingRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Training Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Training</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Course', 'Institution', 'Cert. No.', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.course}</Text></Table.Td>
|
||||
<Table.Td>{r.institution}</Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View training — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No training records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Medical
|
||||
// ---------------------------------------------------------------------------
|
||||
function MedicalTab({ records, onAdd }: { records: MedicalRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Medical Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Record</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Exam Type', 'Issued By', 'Issue Date', 'Expiry', 'Result', 'Remarks', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.examType}</Text></Table.Td>
|
||||
<Table.Td>{r.issuedBy}</Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.result} /></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="dimmed" style={{ maxWidth: rem(180) }} lineClamp={1}>{r.remarks}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View medical record — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No medical records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Sea Service
|
||||
// ---------------------------------------------------------------------------
|
||||
function SeaServiceTab({ records, onAdd }: { records: SeaServiceRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Sea Service Records</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Service</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Vessel Name', 'Type', 'Rank', 'Flag', 'From', 'To', 'Engagement Port', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.vesselName}</Text></Table.Td>
|
||||
<Table.Td>{r.vesselType}</Table.Td>
|
||||
<Table.Td>{r.rank}</Table.Td>
|
||||
<Table.Td>{r.flag}</Table.Td>
|
||||
<Table.Td>{r.from}</Table.Td>
|
||||
<Table.Td>{r.to}</Table.Td>
|
||||
<Table.Td>{r.engagementPort}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View sea service — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No sea service records found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Certifications
|
||||
// ---------------------------------------------------------------------------
|
||||
function CertificationsTab({ records, onAdd }: { records: CertificationRecord[]; onAdd: () => void }) {
|
||||
return (
|
||||
<Paper withBorder radius="md">
|
||||
<Group justify="space-between" p="md">
|
||||
<Text fw={700} fz="sm">Certifications</Text>
|
||||
<Button size="xs" leftSection={<IconPlus size={13} />} onClick={onAdd}>+ Add Certification</Button>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate', 'Cert. No.', 'Type', 'Issued By', 'Issue Date', 'Expiry', 'Status', 'Actions'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{records.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Text fw={500} fz="sm">{r.name}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{r.certNo}</Text></Table.Td>
|
||||
<Table.Td><Badge variant="outline" size="xs" radius="sm">{r.type}</Badge></Table.Td>
|
||||
<Table.Td>{r.issuedBy}</Table.Td>
|
||||
<Table.Td>{r.issueDate}</Table.Td>
|
||||
<Table.Td>{r.expiry}</Table.Td>
|
||||
<Table.Td><Chip value={r.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" onClick={() => notify.info('View certificate — coming soon.')}>View</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{records.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No certifications found.</Text>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: History
|
||||
// ---------------------------------------------------------------------------
|
||||
function HistoryTab({ entries }: { entries: HistoryEntry[] }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="md">Activity History</Text>
|
||||
<Divider mb="md" />
|
||||
<Stack gap="sm">
|
||||
{entries.map((e) => (
|
||||
<Group key={e.id} gap="md" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" color="blue" size={32} radius="xl" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<IconClock size={15} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fz="sm" fw={600}>{e.action}</Text>
|
||||
<Text fz="xs" c="dimmed">by {e.performedBy}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{e.date}</Text>
|
||||
{e.notes && <Text fz="xs" mt={2}>{e.notes}</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
{entries.length === 0 && <Text ta="center" c="dimmed" fz="sm" py="xl">No history found.</Text>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Record Modal (generic)
|
||||
// ---------------------------------------------------------------------------
|
||||
function AddTrainingModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Training Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Course Name" placeholder="e.g. Personal Survival Techniques" required />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Institution" placeholder="Training institution" />
|
||||
<TextInput label="Certificate No." placeholder="CERT-0000" />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" />
|
||||
<TextInput label="Expiry Date" type="date" />
|
||||
</SimpleGrid>
|
||||
<Select label="Status" data={['Approved', 'Pending', 'Expired']} defaultValue="Pending" />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Training record added (demo).'); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Medical Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Exam Type" placeholder="e.g. STCW Medical Certificate" required />
|
||||
<TextInput label="Issued By" placeholder="Issuing authority" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" />
|
||||
<TextInput label="Expiry Date" type="date" />
|
||||
</SimpleGrid>
|
||||
<Select label="Result" data={['Fit', 'Unfit', 'Conditional']} defaultValue="Fit" />
|
||||
<Textarea label="Remarks" placeholder="Any notes" autosize minRows={2} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Medical record added (demo).'); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Vessel Name" placeholder="MV Name" required />
|
||||
<TextInput label="Vessel Type" placeholder="e.g. Bulk Carrier" />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
|
||||
<TextInput label="Flag" placeholder="Country" />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="From" type="date" />
|
||||
<TextInput label="To" type="date" />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Engagement Port" placeholder="Port name" />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Sea service record added (demo).'); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCertModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Add Certification" size="lg">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="Certificate Name" placeholder="e.g. STCW Basic Safety Training" required />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Certificate No." placeholder="CERT-0000" />
|
||||
<Select label="Type" data={['STCW', 'COC', 'COE', 'GMDSS', 'Other']} placeholder="Select type" />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Issued By" placeholder="Issuing authority" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" />
|
||||
<TextInput label="Expiry Date" type="date" />
|
||||
</SimpleGrid>
|
||||
<Select label="Status" data={['Valid', 'Expired', 'Pending']} defaultValue="Valid" />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={() => { notify.success('Certification added (demo).'); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerProfilePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<SeafarerProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string | null>('overview');
|
||||
|
||||
const [trainingModal, trainingModalHandlers] = useDisclosure(false);
|
||||
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
|
||||
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
|
||||
const [certModal, certModalHandlers] = useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchSeafarerProfile(id)
|
||||
.then(setProfile)
|
||||
.catch(() => notify.error('Failed to load seafarer profile.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleStatusChange = async (newStatus: 'Active' | 'Suspended') => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
await updateSeafarerStatus(profile.id, newStatus);
|
||||
setProfile((p) => p ? { ...p, status: newStatus } : p);
|
||||
notify.success(`Status updated to ${newStatus}.`);
|
||||
} catch {
|
||||
notify.error('Failed to update status.');
|
||||
}
|
||||
};
|
||||
|
||||
const initials = profile ? `${profile.firstName[0]}${profile.lastName[0]}` : '??';
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Breadcrumb */}
|
||||
<Group gap="xs" align="center">
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => navigate('/seafarer-registry')}>
|
||||
<IconArrowLeft size={16} />
|
||||
</ActionIcon>
|
||||
<Text fz="sm" c="dimmed" style={{ cursor: 'pointer' }} onClick={() => navigate('/seafarer-registry')}>
|
||||
Seafarer Registry
|
||||
</Text>
|
||||
<Text fz="sm" c="dimmed">/</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{loading ? <Skeleton width={100} height={14} /> : `${profile?.firstName} ${profile?.lastName}`}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Profile header card */}
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
{loading ? (
|
||||
<Group gap="md">
|
||||
<Skeleton circle height={64} />
|
||||
<Stack gap={6} style={{ flex: 1 }}>
|
||||
<Skeleton height={20} width={200} />
|
||||
<Skeleton height={14} width={300} />
|
||||
<Skeleton height={14} width={400} />
|
||||
</Stack>
|
||||
</Group>
|
||||
) : profile ? (
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="lg" wrap="nowrap" align="flex-start">
|
||||
<Avatar size={64} radius="xl" color="blue" style={{ fontSize: rem(22) }}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title order={3} lh={1.2}>{profile.firstName} {profile.lastName}</Title>
|
||||
<Text fz="sm" c="dimmed" mt={2}>
|
||||
{profile.seafarerId} · Registered {profile.registeredAt}
|
||||
</Text>
|
||||
<Group gap="lg" mt={6} wrap="wrap">
|
||||
<Text fz="sm"><Text span fw={600}>Gender:</Text> {profile.gender}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>DOB:</Text> {profile.dob}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Nationality:</Text> {profile.nationality}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Mobile:</Text> {profile.mobile}</Text>
|
||||
<Text fz="sm"><Text span fw={600}>Email:</Text> {profile.email}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
|
||||
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconPrinter size={13} />} onClick={() => notify.info('Print — coming soon.')}>
|
||||
Print Profile
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
) : (
|
||||
<Alert color="red">Profile not found.</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
{!loading && profile && (
|
||||
<Tabs value={activeTab} onChange={setActiveTab} variant="outline">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="overview" leftSection={<IconLayoutDashboard size={15} />}>Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="training" leftSection={<IconBook size={15} />}>Training</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={15} />}>Medical</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconShip size={15} />}>Sea Service</Tabs.Tab>
|
||||
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={15} />}>Certifications</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<IconHistory size={15} />}>History</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewTab profile={profile} onStatusChange={handleStatusChange} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="training">
|
||||
<TrainingTab records={profile.training} onAdd={trainingModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical">
|
||||
<MedicalTab records={profile.medical} onAdd={medicalModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service">
|
||||
<SeaServiceTab records={profile.seaService} onAdd={seaServiceModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="certifications">
|
||||
<CertificationsTab records={profile.certifications} onAdd={certModalHandlers.open} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryTab entries={profile.history} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AddTrainingModal opened={trainingModal} onClose={trainingModalHandlers.close} />
|
||||
<AddMedicalModal opened={medicalModal} onClose={medicalModalHandlers.close} />
|
||||
<AddSeaServiceModal opened={seaServiceModal} onClose={seaServiceModalHandlers.close} />
|
||||
<AddCertModal opened={certModal} onClose={certModalHandlers.close} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconSchool,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API
|
||||
// ---------------------------------------------------------------------------
|
||||
async function submitSeafarerRegistration(data: unknown): Promise<{ ok: true; referenceId: string }> {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('Seafarer registration payload:', data);
|
||||
return { ok: true, referenceId: `SEA-${Date.now()}` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const NATIONALITIES = [
|
||||
'Ethiopian', 'Eritrean', 'Djiboutian', 'Kenyan', 'Somali', 'Sudanese', 'Other',
|
||||
];
|
||||
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
|
||||
const GENDERS = ['Male', 'Female'];
|
||||
const REGIONS = [
|
||||
'Addis Ababa', 'Dire Dawa', 'Amhara', 'Oromia', 'Tigray',
|
||||
'Afar', 'Somali', 'Sidama', 'South Ethiopia', 'Gambela',
|
||||
'Benishangul-Gumuz', 'Harari',
|
||||
];
|
||||
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Personal Information' },
|
||||
{ label: 'Contact Details' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'nationalId', label: 'National ID (Front & Back)', description: 'Both sides of your national identity card', required: true, icon: IconId },
|
||||
{ key: 'passport', label: 'Passport Copy', description: 'Bio-data page of valid passport', required: false, icon: IconFileDescription },
|
||||
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification', required: false, icon: IconSchool },
|
||||
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5cm', required: true, icon: IconCamera },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb="lg">
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
{/* Circle */}
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(36),
|
||||
height: rem(36),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isDone ? (
|
||||
<IconCheck size={18} color="white" stroke={2.5} />
|
||||
) : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'dimmed'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={isCurrent ? 700 : 400}
|
||||
c={isCurrent ? 'blue.7' : isDone ? 'dimmed' : 'dimmed'}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Connector line */}
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: 'var(--mantine-color-gray-3)',
|
||||
marginBottom: rem(20),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section heading
|
||||
// ---------------------------------------------------------------------------
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Text fw={600} fz="sm" mt={4} mb={2}>{title}</Text>
|
||||
<Divider mb={6} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review row
|
||||
// ---------------------------------------------------------------------------
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document upload card
|
||||
// ---------------------------------------------------------------------------
|
||||
function DocCard({
|
||||
slot,
|
||||
file,
|
||||
onFile,
|
||||
}: {
|
||||
slot: DocSlot;
|
||||
file: File | null;
|
||||
onFile: (f: File | null) => void;
|
||||
}) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: rem(44),
|
||||
height: rem(44),
|
||||
borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">
|
||||
{slot.label}
|
||||
{slot.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => { onFile(null); resetRef.current?.(); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="xs" variant="default" {...props}>
|
||||
📂 Choose File
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistrationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 1 — Personal Information
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [middleName, setMiddleName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [gender, setGender] = useState<string | null>(null);
|
||||
const [dob, setDob] = useState('');
|
||||
const [placeOfBirth, setPlaceOfBirth] = useState('');
|
||||
const [nationality, setNationality] = useState<string | null>('Ethiopian');
|
||||
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
|
||||
const [nationalIdNumber, setNationalIdNumber] = useState('');
|
||||
const [passportNumber, setPassportNumber] = useState('');
|
||||
const [passportExpiry, setPassportExpiry] = useState('');
|
||||
|
||||
// Step 2 — Contact Details
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [region, setRegion] = useState<string | null>(null);
|
||||
const [city, setCity] = useState('');
|
||||
const [permanentAddress, setPermanentAddress] = useState('');
|
||||
const [currentAddress, setCurrentAddress] = useState('');
|
||||
const [emergencyName, setEmergencyName] = useState('');
|
||||
const [emergencyRel, setEmergencyRel] = useState<string | null>(null);
|
||||
const [emergencyPhone, setEmergencyPhone] = useState('');
|
||||
|
||||
// Step 3 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
nationalId: null, passport: null, graduation: null, photo: null,
|
||||
});
|
||||
|
||||
const setFile = (key: string) => (f: File | null) =>
|
||||
setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return !!firstName.trim() && !!lastName.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
|
||||
if (active === 1) return !!mobile.trim() && !!email.trim() && !!region && !!city.trim();
|
||||
if (active === 2) return !!files.nationalId && !!files.photo;
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await submitSeafarerRegistration({
|
||||
personalInfo: { firstName, middleName, lastName, gender, dob, placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
|
||||
contactDetails: { mobile, email, region, city, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
|
||||
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
|
||||
});
|
||||
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
|
||||
navigate('/applications');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepLabel = STEPS[active]?.label ?? '';
|
||||
const stepIcons = [IconUser, IconAddressBook, IconFileDescription, IconCircleCheck];
|
||||
const StepIcon = stepIcons[active];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<Title order={3}>New Seafarer Registration</Title>
|
||||
<Text fz="sm" c="dimmed">Register a new seafarer profile — Step {active + 1} of {STEPS.length}</Text>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{/* Card */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{/* Card header */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<StepIcon size={20} stroke={1.6} />
|
||||
<Text fw={700} fz="lg">{stepLabel}</Text>
|
||||
</Group>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* ── Step 1: Personal Information ───────────────────────────── */}
|
||||
{active === 0 && (
|
||||
<Stack gap={8}>
|
||||
<SectionHead title="Identity Details" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={8}>
|
||||
<TextInput label="First Name" placeholder="firstname" required value={firstName} onChange={(e) => setFirstName(e.currentTarget.value)} />
|
||||
<TextInput label="Middle Name" placeholder="Middle name" value={middleName} onChange={(e) => setMiddleName(e.currentTarget.value)} />
|
||||
<TextInput label="Last Name" placeholder="Last name" required value={lastName} onChange={(e) => setLastName(e.currentTarget.value)} />
|
||||
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
|
||||
<TextInput label="Date of Birth" type="date" required value={dob} onChange={(e) => setDob(e.currentTarget.value)} />
|
||||
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
|
||||
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
|
||||
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Identity Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={8}>
|
||||
<TextInput label="National ID Number" placeholder="ET-000000" required value={nationalIdNumber} onChange={(e) => setNationalIdNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Passport Number" placeholder="EP000000" value={passportNumber} onChange={(e) => setPassportNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Passport Expiry Date" type="date" value={passportExpiry} onChange={(e) => setPassportExpiry(e.currentTarget.value)} style={{ maxWidth: rem(360) }} />
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
A unique Seafarer ID will be automatically generated upon approval of this registration.
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Contact Details ─────────────────────────────────── */}
|
||||
{active === 1 && (
|
||||
<Stack gap={8}>
|
||||
<SectionHead title="Contact Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={8}>
|
||||
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
|
||||
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
||||
<Select label="Region" placeholder="Select region" required data={REGIONS} value={region} onChange={setRegion} searchable />
|
||||
<TextInput label="City" placeholder="City" required value={city} onChange={(e) => setCity(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
|
||||
<Textarea
|
||||
label={<><Text span fz="sm" fw={500}>Current Address</Text><Text span fz="xs" c="dimmed" ml={6}>(If different from permanent)</Text></>}
|
||||
placeholder="Full current address"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={currentAddress}
|
||||
onChange={(e) => setCurrentAddress(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<SectionHead title="Emergency Contact" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={8}>
|
||||
<TextInput label="Contact Name" placeholder="Full name" value={emergencyName} onChange={(e) => setEmergencyName(e.currentTarget.value)} />
|
||||
<Select label="Relationship" placeholder="Select" data={RELATIONSHIPS} value={emergencyRel} onChange={setEmergencyRel} />
|
||||
</SimpleGrid>
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" value={emergencyPhone} onChange={(e) => setEmergencyPhone(e.currentTarget.value)} style={{ maxWidth: rem(360) }} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Documents Upload ────────────────────────────────── */}
|
||||
{active === 2 && (
|
||||
<Stack gap="sm">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
|
||||
Please upload clear, readable copies of all required documents. Accepted formats: PDF, JPG, PNG (max 5MB each). Items marked with * are mandatory.
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
file={files[slot.key]}
|
||||
onFile={setFile(slot.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fw={600} fz="sm" mb={6}>Upload Progress</Text>
|
||||
<Group gap="lg">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap={6} align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 14, height: 14, borderRadius: '50%', border: '1.5px solid var(--mantine-color-gray-4)' }} />
|
||||
)}
|
||||
<Text fz="xs" c={files[slot.key] ? 'teal.7' : 'dimmed'}>
|
||||
{slot.key === 'nationalId' ? 'National ID' : slot.key === 'passport' ? 'Passport' : slot.key === 'graduation' ? 'Certificate' : 'Photo'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review & Submit ─────────────────────────────────── */}
|
||||
{active === 3 && (
|
||||
<Stack gap="sm">
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fw={700} fz="sm" mb="xs">Personal Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
<ReviewRow label="First Name" value={firstName} />
|
||||
<ReviewRow label="Middle Name" value={middleName} />
|
||||
<ReviewRow label="Last Name" value={lastName} />
|
||||
<ReviewRow label="Gender" value={gender ?? ''} />
|
||||
<ReviewRow label="Date of Birth" value={dob} />
|
||||
<ReviewRow label="Place of Birth" value={placeOfBirth} />
|
||||
<ReviewRow label="Nationality" value={nationality ?? ''} />
|
||||
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />
|
||||
<ReviewRow label="National ID No." value={nationalIdNumber} />
|
||||
<ReviewRow label="Passport No." value={passportNumber} />
|
||||
<ReviewRow label="Passport Expiry" value={passportExpiry} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fw={700} fz="sm" mb="xs">Contact Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
<ReviewRow label="Mobile" value={mobile} />
|
||||
<ReviewRow label="Email" value={email} />
|
||||
<ReviewRow label="Region" value={region ?? ''} />
|
||||
<ReviewRow label="City" value={city} />
|
||||
<ReviewRow label="Permanent Address" value={permanentAddress} />
|
||||
<ReviewRow label="Current Address" value={currentAddress} />
|
||||
</SimpleGrid>
|
||||
{emergencyName && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" mt="sm" mb={4}>Emergency Contact</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
<ReviewRow label="Name" value={emergencyName} />
|
||||
<ReviewRow label="Relationship" value={emergencyRel ?? ''} />
|
||||
<ReviewRow label="Phone" value={emergencyPhone} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Text fw={700} fz="sm" mb="xs">Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs" align="center">
|
||||
{files[slot.key] ? (
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<Box style={{ width: 16, height: 16, borderRadius: '50%', border: '1.5px solid var(--mantine-color-gray-4)', flexShrink: 0 }} />
|
||||
)}
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label}
|
||||
{slot.required && !files[slot.key] && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{files[slot.key] && (
|
||||
<Text fz="xs" c="dimmed" truncate style={{ flex: 1 }}>({files[slot.key]!.name})</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" onClick={() => navigate('/applications')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={next}
|
||||
disabled={!canNext()}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileExport,
|
||||
IconSearch,
|
||||
IconUserCheck,
|
||||
IconUsers,
|
||||
IconUserX,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface Seafarer {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
gender: 'Male' | 'Female';
|
||||
nationality: string;
|
||||
mobile: string;
|
||||
region: string;
|
||||
registeredAt: string;
|
||||
medicalStatus: 'Fit' | 'Unfit' | 'Pending';
|
||||
bookStatus: 'Active' | 'Expired' | 'Suspended' | 'Pending';
|
||||
status: 'Active' | 'Pending' | 'Suspended';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dummy API — replace with real fetch later
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchSeafarers(): Promise<Seafarer[]> {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
seafarerId: 'SF-2024-0001',
|
||||
firstName: 'Abebe',
|
||||
lastName: 'Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 911 234 567',
|
||||
region: 'Addis Ababa',
|
||||
registeredAt: '2024-01-10',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
seafarerId: 'SF-2024-0002',
|
||||
firstName: 'Sara',
|
||||
lastName: 'Tadesse',
|
||||
email: 'sara.t@email.com',
|
||||
gender: 'Female',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 922 345 678',
|
||||
region: 'Dire Dawa',
|
||||
registeredAt: '2024-02-14',
|
||||
medicalStatus: 'Pending',
|
||||
bookStatus: 'Pending',
|
||||
status: 'Pending',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
seafarerId: 'SF-2024-0003',
|
||||
firstName: 'Dawit',
|
||||
lastName: 'Bekele',
|
||||
email: 'dawit.b@email.com',
|
||||
gender: 'Male',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 933 456 789',
|
||||
region: 'Oromia',
|
||||
registeredAt: '2024-03-05',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Expired',
|
||||
status: 'Suspended',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
seafarerId: 'SF-2024-0004',
|
||||
firstName: 'Hana',
|
||||
lastName: 'Mulugeta',
|
||||
email: 'hana.m@email.com',
|
||||
gender: 'Female',
|
||||
nationality: 'Ethiopian',
|
||||
mobile: '+251 944 567 890',
|
||||
region: 'Amhara',
|
||||
registeredAt: '2024-04-20',
|
||||
medicalStatus: 'Fit',
|
||||
bookStatus: 'Active',
|
||||
status: 'Active',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat card
|
||||
// ---------------------------------------------------------------------------
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconUsers;
|
||||
color: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
{loading ? (
|
||||
<Skeleton height={28} width={40} mb={6} />
|
||||
) : (
|
||||
<Title order={2} lh={1}>{value}</Title>
|
||||
)}
|
||||
<Text fz="sm" c="dimmed" mt={4}>{label}</Text>
|
||||
</div>
|
||||
<ThemeIcon variant="light" color={color} size={46} radius="md">
|
||||
<Icon size={22} stroke={1.6} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badges
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Active: 'teal',
|
||||
Pending: 'yellow',
|
||||
Suspended: 'red',
|
||||
Expired: 'orange',
|
||||
Fit: 'teal',
|
||||
Unfit: 'red',
|
||||
};
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<Badge
|
||||
color={STATUS_COLOR[value] ?? 'gray'}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function SeafarerRegistryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [seafarers, setSeafarers] = useState<Seafarer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeafarers()
|
||||
.then(setSeafarers)
|
||||
.catch(() => notify.error('Failed to load seafarers.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const stats = {
|
||||
total: seafarers.length,
|
||||
active: seafarers.filter((s) => s.status === 'Active').length,
|
||||
pending: seafarers.filter((s) => s.status === 'Pending').length,
|
||||
suspended: seafarers.filter((s) => s.status === 'Suspended').length,
|
||||
};
|
||||
|
||||
const filtered = seafarers.filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch =
|
||||
!q ||
|
||||
s.seafarerId.toLowerCase().includes(q) ||
|
||||
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
|
||||
s.mobile.includes(q) ||
|
||||
s.email.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || s.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const rows = filtered.map((s) => (
|
||||
<Table.Tr key={s.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={600} c="blue.7" style={{ cursor: 'pointer' }} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
{s.seafarerId}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>{s.firstName} {s.lastName}</Text>
|
||||
<Text fz="xs" c="dimmed">{s.email}</Text>
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.gender}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.nationality}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
|
||||
<Table.Td><StatusBadge value={s.status} /></Table.Td>
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDotsVertical size={15} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
View
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => navigate(`/seafarer-registry/${s.id}`)}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconX size={14} />} color="red" onClick={() => notify.info('Suspend — coming soon.')}>
|
||||
Suspend
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Manage all registered seafarers</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconAnchor size={16} />}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
+ New Registration
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatCard label="Total Seafarers" value={stats.total} icon={IconUsers} color="blue" loading={loading} />
|
||||
<StatCard label="Active" value={stats.active} icon={IconUserCheck} color="teal" loading={loading} />
|
||||
<StatCard label="Pending" value={stats.pending} icon={IconClock} color="yellow" loading={loading} />
|
||||
<StatCard label="Suspended" value={stats.suspended} icon={IconUserX} color="red" loading={loading} />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table card */}
|
||||
<Paper withBorder radius="md">
|
||||
{/* Toolbar */}
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Seafarer List</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, ID or mobile…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ minWidth: rem(260) }}
|
||||
size="sm"
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}>
|
||||
<IconX size={13} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Active', 'Pending', 'Suspended']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(140) }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={34}
|
||||
title="Export"
|
||||
onClick={() => notify.info('Export — coming soon.')}
|
||||
>
|
||||
<IconFileExport size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<Stack gap="xs" p="md">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} height={44} radius="sm" />)}
|
||||
</Stack>
|
||||
) : filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconUsers size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No seafarers found</Text>
|
||||
{(search || statusFilter) && (
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setStatusFilter(null); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped withColumnBorders={false} verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer ID', 'Name', 'Gender', 'Nationality', 'Mobile', 'Region', 'Reg. Date', 'Medical', 'Book Status', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ whiteSpace: 'nowrap', fontSize: rem(11), textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--mantine-color-dimmed)' }}>
|
||||
{h}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {seafarers.length} seafarers</Text>
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="dimmed">Data loaded</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +1,270 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Burger,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Menu,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconLayoutDashboard,
|
||||
IconCategory,
|
||||
IconFilePlus,
|
||||
IconFileDescription,
|
||||
IconAnchor,
|
||||
IconBell,
|
||||
IconCertificate,
|
||||
IconUser,
|
||||
IconHelpCircle,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconFileDescription,
|
||||
IconFolder,
|
||||
IconLayoutDashboard,
|
||||
IconLifebuoy,
|
||||
IconList,
|
||||
IconLogout,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
to?: string;
|
||||
soon?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/services', labelKey: 'nav.services', icon: IconCategory },
|
||||
{ to: '/apply', labelKey: 'nav.apply', icon: IconFilePlus },
|
||||
{ to: '/applications', labelKey: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: '/licenses', labelKey: 'nav.licenses', icon: IconCertificate },
|
||||
{ to: '/profile', labelKey: 'nav.profile', icon: IconUser },
|
||||
{ to: '/support', labelKey: 'nav.support', icon: IconHelpCircle },
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/applications', label: 'My Applications', icon: IconFileDescription },
|
||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconList },
|
||||
{ to: '/seafarer-registration', label: 'New Registration', icon: IconAnchor },
|
||||
{ to: '/licenses', label: 'My Licenses', icon: IconCertificate },
|
||||
{ label: 'Documents', icon: IconFolder, soon: true },
|
||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
||||
{ to: '/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',
|
||||
},
|
||||
'/seafarer-registry': {
|
||||
title: 'Seafarer Registry',
|
||||
subtitle: 'Manage all registered seafarers',
|
||||
},
|
||||
'/seafarer-registration': {
|
||||
title: 'New Seafarer Registration',
|
||||
subtitle: 'Register a new seafarer profile',
|
||||
},
|
||||
'/profile': {
|
||||
title: 'Profile',
|
||||
subtitle: 'Manage your account and preferences',
|
||||
},
|
||||
};
|
||||
|
||||
export function PortalLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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 go = (to: string) => {
|
||||
navigate(to);
|
||||
close();
|
||||
const meta = PAGE_META[location.pathname] ?? {
|
||||
title: 'EMA Portal',
|
||||
subtitle: 'Ethiopian Maritime Authority',
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
const go = (item: NavItem) => {
|
||||
if (item.soon) {
|
||||
notify.info(`${item.label} — coming soon.`);
|
||||
return;
|
||||
}
|
||||
if (item.to) {
|
||||
navigate(item.to);
|
||||
closeNav();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 260,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
}}
|
||||
padding="md"
|
||||
header={{ height: 74 }}
|
||||
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
|
||||
padding="lg"
|
||||
>
|
||||
{/* ---- 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">
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<UnstyledButton onClick={() => go('/dashboard')}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Logo variant="color" size={36} />
|
||||
<div>
|
||||
<Text fw={700} lh={1.1}>
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.1} visibleFrom="xs">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Burger opened={navOpened} onClick={toggleNav} 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={4} wrap="nowrap">
|
||||
<ColorSchemeToggle />
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<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>
|
||||
</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}>
|
||||
<Stack gap={2}>
|
||||
<Stack gap={4}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const ActiveIcon = item.icon;
|
||||
const ItemIcon = item.icon;
|
||||
const active =
|
||||
location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`);
|
||||
!!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 (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
key={item.label}
|
||||
active={active}
|
||||
label={t(item.labelKey)}
|
||||
leftSection={<ActiveIcon size={20} stroke={1.6} />}
|
||||
onClick={() => go(item.to)}
|
||||
label={item.label}
|
||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||
onClick={() => go(item)}
|
||||
variant="light"
|
||||
styles={{ root: { borderRadius: rem(8) } }}
|
||||
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section>
|
||||
<Text size="xs" c="dimmed" ta="center" py="xs">
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
{/* Collapse toggle */}
|
||||
<AppShell.Section pt="xs">
|
||||
<Tooltip
|
||||
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.Navbar>
|
||||
|
||||
@@ -142,45 +276,3 @@ export function PortalLayout() {
|
||||
</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
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
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 { ApplicationsListPage } from './features/licenses/pages/ApplicationsListPage';
|
||||
import { ApplicationDetailPage } from './features/licenses/pages/ApplicationDetailPage';
|
||||
@@ -23,6 +17,9 @@ import { MyLicensesPage } from './features/licenses/pages/MyLicensesPage';
|
||||
import { LicenseDetailPage } from './features/licenses/pages/LicenseDetailPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
import { SupportPage } from './features/support/pages/SupportPage';
|
||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
|
||||
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
|
||||
|
||||
// IAM (admin user management) — kept reachable but isolated under its own
|
||||
// provider so it does not depend on the portal's provider tree.
|
||||
@@ -43,9 +40,8 @@ export const router = createBrowserRouter([
|
||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
|
||||
// Portal (open access for this UI template).
|
||||
// Wrapped in the portal's own i18n instance so it is isolated from the
|
||||
// global i18next singleton that `@tria-plc/iamui-common` initializes.
|
||||
// Portal — wrapped in the portal's own i18n instance so it is isolated from
|
||||
// the global i18next singleton that `@tria-plc/iamui-common` initializes.
|
||||
{
|
||||
element: (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
@@ -57,6 +53,9 @@ export const router = createBrowserRouter([
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/services', element: <ServicesPage /> },
|
||||
{ path: '/apply', element: <ApplyPage /> },
|
||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
||||
{ path: '/applications', element: <ApplicationsListPage /> },
|
||||
{ path: '/applications/:id', element: <ApplicationDetailPage /> },
|
||||
{ path: '/licenses', element: <MyLicensesPage /> },
|
||||
@@ -66,22 +65,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)
|
||||
{
|
||||
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