mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 02:30:57 +00:00
second design options
This commit is contained in:
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://localhost:4200/v2/dashboard)",
|
||||
"Bash(pkill -f \"nx run @ema-platform/portal:serve\")",
|
||||
"Bash(pkill -f \"vite\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
|
||||
// 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';
|
||||
@@ -61,6 +67,22 @@ 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: (
|
||||
|
||||
210
apps/portal/src/app/v2/PortalLayoutV2.tsx
Normal file
210
apps/portal/src/app/v2/PortalLayoutV2.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
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 '../features/auth/components/AuthShell';
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
apps/portal/src/app/v2/components/ui.tsx
Normal file
61
apps/portal/src/app/v2/components/ui.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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';
|
||||
|
||||
/** 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];
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={color}
|
||||
radius="sm"
|
||||
tt="none"
|
||||
fw={600}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
background: `var(--mantine-color-${color}-filled)`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{applicationStatus(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular avatar filled with the EMA brand gradient. */
|
||||
export function BrandAvatar({
|
||||
initials = 'AB',
|
||||
size = 38,
|
||||
}: {
|
||||
initials?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<Box
|
||||
w={size}
|
||||
h={size}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
background: theme.other.heroGradient as string,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.36,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
352
apps/portal/src/app/v2/pages/ApplicationsV2.tsx
Normal file
352
apps/portal/src/app/v2/pages/ApplicationsV2.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
470
apps/portal/src/app/v2/pages/ApplyV2.tsx
Normal file
470
apps/portal/src/app/v2/pages/ApplyV2.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
303
apps/portal/src/app/v2/pages/DashboardV2.tsx
Normal file
303
apps/portal/src/app/v2/pages/DashboardV2.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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