Merge pull request #68 from Tria-plc/WorkflowChange

Workflow change
This commit is contained in:
Nati Nigussie
2026-09-07 12:08:24 +03:00
committed by GitHub
47 changed files with 5686 additions and 449 deletions

View File

@@ -36,8 +36,10 @@ import {
useApiQuery,
useBypassPaymentMutation,
useDiscardApplicationMutation,
useGetLicenseTypesQuery,
useGetCertificateUrlMutation,
useGetPaymentCapabilitiesQuery,
type LicenseType,
} from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
@@ -146,6 +148,45 @@ function formatDate(value: string | null | undefined): string {
});
}
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();
if (!token) throw new Error('No auth token found');
const res = await fetch(
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
return res.blob();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* A licence type's validity the way a certificate would print it: days win
* over months, whole years read as years.
*/
function validityLabel(type: LicenseType): string {
if (type.validityDays) {
return `${type.validityDays} ${type.validityDays === 1 ? 'day' : 'days'}`;
}
const months = type.validityMonths || 12;
if (months % 12 === 0) {
const years = months / 12;
return `${years} ${years === 1 ? 'year' : 'years'}`;
}
return `${months} ${months === 1 ? 'month' : 'months'}`;
}
export function CertificatesPage() {
const navigate = useNavigate();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -234,6 +275,33 @@ export function CertificatesPage() {
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
// The certificate types on offer and how long each is valid come from the
// licence-type rows the backoffice configures (certificate category and
// validity on the Behaviour tab), not from a list kept here.
const { data: licenseTypes } = useGetLicenseTypesQuery();
const certificateTypes = (licenseTypes?.items ?? []).filter(
(type) =>
type.isActive !== false &&
(type.certificateCategory === 'COC' || type.certificateCategory === 'COP'),
);
const applyTargets =
certificateTypes.length > 0
? certificateTypes.map((type) => ({
key: type.key,
label: type.certificateCategory === 'COC' ? 'CoC' : 'CoP',
variant: (type.certificateCategory === 'COC' ? 'filled' : 'light') as 'filled' | 'light',
}))
: // Before the catalogue loads, or on a server that has not seeded it.
[
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
];
const validityTerms = [...new Set(certificateTypes.map(validityLabel))];
const validityDescription =
validityTerms.length > 0
? `CoC/CoP certificates are valid for ${validityTerms.join(' or ')} and must be revalidated before expiry.`
: 'CoC/CoP certificates are valid for a fixed term and must be revalidated before expiry.';
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
@@ -296,10 +364,7 @@ export function CertificatesPage() {
disabled, so the reason still shows on hover. */}
<span>
<Group gap="xs">
{([
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
]).map(({ key, label, variant }) => {
{applyTargets.map(({ key, label, variant }) => {
const hasDraft = draftTypeKeys.has(key);
return (
<Button
@@ -337,7 +402,7 @@ export function CertificatesPage() {
{[
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
{ icon: IconClock, color: 'orange', title: 'Validity', desc: validityDescription },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>

View File

@@ -0,0 +1,361 @@
import { useState } from 'react';
import {
Box,
Card,
Center,
ColorSwatch,
Flex,
Group,
SegmentedControl,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import { IconCategory, IconChartPie, IconFileAnalytics, IconTrendingUp } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
export interface StatusDistributionItem {
name: string;
value: number;
color: string;
}
export interface MonthlyTrendItem {
month: string;
year: number;
submitted: number;
approved: number;
}
export interface CategoryBreakdownItem {
category: string;
count: number;
}
export interface DashboardChartsProps {
statusDistribution: StatusDistributionItem[];
monthlyTrend: MonthlyTrendItem[];
categoryBreakdown?: CategoryBreakdownItem[];
totalApplications: number;
}
const CATEGORY_COLORS = ['#2a59ac', '#1bb497', '#4c6ef5', '#fab005', '#e64980', '#12b886'];
export function DashboardCharts({
monthlyTrend,
categoryBreakdown = [],
totalApplications,
}: DashboardChartsProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<'timeline' | 'category'>('timeline');
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const tooltipStyle = {
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
border: isDark
? '1px solid var(--mantine-color-dark-4, #373a40)'
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
borderRadius: '8px',
boxShadow: isDark
? '0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.08)',
padding: '8px 12px',
fontSize: '12px',
color: isDark ? '#c1c2c5' : '#212529',
};
const gridColor = isDark ? 'var(--mantine-color-dark-5, #2c2e33)' : 'var(--mantine-color-gray-2, #e9ecef)';
const tickColor = isDark ? '#909296' : '#868e96';
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" size="md" radius="md">
{viewMode === 'timeline' ? <IconTrendingUp size={18} /> : <IconCategory size={18} />}
</ThemeIcon>
<Box>
<Text fw={700} size="md">
{viewMode === 'timeline'
? t('dashboard.charts.activityTitle')
: t('dashboard.charts.categoryTitle')}
</Text>
<Text size="xs" c="dimmed">
{viewMode === 'timeline'
? t('dashboard.charts.activitySubtitle')
: t('dashboard.charts.categoryCount')}
</Text>
</Box>
</Group>
{totalApplications > 0 && (
<SegmentedControl
size="xs"
radius="md"
value={viewMode}
onChange={(val) => setViewMode(val as 'timeline' | 'category')}
data={[
{ label: t('dashboard.charts.viewTimeline'), value: 'timeline' },
{ label: t('dashboard.charts.viewCategory'), value: 'category' },
]}
/>
)}
</Group>
<Box style={{ height: 220, width: '100%' }}>
{totalApplications === 0 ? (
<Center h={200}>
<Stack align="center" gap={4}>
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconFileAnalytics size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t('dashboard.charts.noData')}
</Text>
</Stack>
</Center>
) : viewMode === 'timeline' ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={monthlyTrend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="areaSubmissions" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
stopOpacity={isDark ? 0.5 : 0.35}
/>
<stop
offset="95%"
stopColor={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
stopOpacity={0.0}
/>
</linearGradient>
<linearGradient id="areaApproved" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
stopOpacity={isDark ? 0.5 : 0.35}
/>
<stop
offset="95%"
stopColor={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
stopOpacity={0.0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: unknown, name: unknown) => [
value as string | number,
name === 'submitted'
? t('dashboard.charts.submitted')
: t('dashboard.charts.approved'),
]}
/>
<Area
type="monotone"
dataKey="submitted"
stroke={isDark ? '#4dabf7' : 'var(--mantine-color-emaPrimary-6, #2a59ac)'}
strokeWidth={2}
fillOpacity={1}
fill="url(#areaSubmissions)"
name="submitted"
/>
<Area
type="monotone"
dataKey="approved"
stroke={isDark ? '#38d9a9' : 'var(--mantine-color-emaTeal-6, #1bb497)'}
strokeWidth={2}
fillOpacity={1}
fill="url(#areaApproved)"
name="approved"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={categoryBreakdown} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={gridColor} />
<XAxis
dataKey="category"
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: unknown) => [value as string | number, t('dashboard.charts.categoryCount')]}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{categoryBreakdown.map((_entry, index) => (
<Cell
key={`bar-${index}`}
fill={isDark ? '#4dabf7' : CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Box>
</Card>
);
}
export function StatusDonutCard({
statusDistribution,
totalApplications,
}: {
statusDistribution: StatusDistributionItem[];
totalApplications: number;
}) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const nonZeroSlices = statusDistribution.filter((item) => item.value > 0);
const pieData =
nonZeroSlices.length > 0
? nonZeroSlices
: [
{
name: t('dashboard.charts.total'),
value: 1,
color: isDark ? 'var(--mantine-color-dark-4, #373a40)' : '#e9ecef',
},
];
const tooltipStyle = {
backgroundColor: isDark ? 'var(--mantine-color-dark-7, #1a1b1e)' : '#ffffff',
border: isDark
? '1px solid var(--mantine-color-dark-4, #373a40)'
: '1px solid var(--mantine-color-gray-2, #e9ecef)',
borderRadius: '8px',
boxShadow: isDark
? '0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 4px 12px rgba(0, 0, 0, 0.08)',
padding: '8px 12px',
fontSize: '12px',
color: isDark ? '#c1c2c5' : '#212529',
};
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="sm">
<Text fw={700} size="md">
{t('dashboard.charts.statusTitle')}
</Text>
<ThemeIcon variant="light" color="emaPrimary" size="sm" radius="md">
<IconChartPie size={14} />
</ThemeIcon>
</Group>
<Flex
direction={{ base: 'column', xs: 'row', sm: 'row' }}
align="center"
justify="center"
gap="md"
py="xs"
>
<Box style={{ position: 'relative', width: 140, height: 140 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
innerRadius={46}
outerRadius={65}
paddingAngle={nonZeroSlices.length > 1 ? 3 : 0}
dataKey="value"
stroke="none"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} />
</PieChart>
</ResponsiveContainer>
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text fw={700} fz={22} lh={1.1} c={isDark ? 'white' : 'dark'}>
{totalApplications}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.total')}
</Text>
</Box>
</Box>
<Stack gap={10} style={{ flex: 1, width: '100%' }}>
{statusDistribution.map((item) => {
const pct =
totalApplications > 0
? Math.round((item.value / totalApplications) * 100)
: 0;
return (
<Group key={item.name} justify="space-between" wrap="nowrap" gap="xs">
<Group gap={8} wrap="nowrap">
<ColorSwatch size={10} color={item.color} />
<Text size="xs" fw={500} c="dimmed">
{item.name}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={700}>
{item.value}
</Text>
<Text size="xs" c="dimmed" style={{ minWidth: 32, textAlign: 'right' }}>
{pct}%
</Text>
</Group>
</Group>
);
})}
</Stack>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1,100 @@
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
IconAward,
IconCircleCheck,
IconClockHour4,
IconFolder,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export interface DashboardStatsProps {
activeLicenses: number;
pendingApplications: number;
approvedApplications: number;
documentsCount: number;
}
export function DashboardStats({
activeLicenses,
pendingApplications,
approvedApplications,
documentsCount,
}: DashboardStatsProps) {
const { t } = useTranslation();
const stats = [
{
title: t('dashboard.stats.activeLicenses'),
value: activeLicenses,
trend: activeLicenses > 0 ? t('dashboard.stats.allInGoodStanding') : '—',
icon: IconAward,
color: 'teal',
trendColor: 'teal',
},
{
title: t('dashboard.stats.pendingApplications'),
value: pendingApplications,
trend: pendingApplications > 0 ? t('dashboard.stats.inReview') : '0 pending',
icon: IconClockHour4,
color: 'orange',
trendColor: 'orange',
},
{
title: t('dashboard.stats.approved'),
value: approvedApplications,
trend: approvedApplications > 0 ? t('dashboard.stats.passRate') : '—',
icon: IconCircleCheck,
color: 'green',
trendColor: 'green',
},
{
title: t('dashboard.stats.documents'),
value: documentsCount,
trend: documentsCount > 0 ? t('dashboard.stats.upToDate') : '—',
icon: IconFolder,
color: 'emaPrimary',
trendColor: 'emaPrimary',
},
];
return (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{stats.map((stat) => {
const IconComponent = stat.icon;
return (
<Card
key={stat.title}
withBorder
radius="md"
padding="lg"
style={{
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={6}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: '0.5px' }}>
{stat.title}
</Text>
<Text fz={32} fw={700} lh={1.1} style={{ letterSpacing: '-0.5px' }}>
{stat.value}
</Text>
<Text size="xs" fw={600} c={stat.trendColor}>
{stat.trend}
</Text>
</Stack>
<ThemeIcon
size={48}
radius="md"
variant="light"
color={stat.color}
>
<IconComponent size={24} stroke={1.8} />
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
);
}

View File

@@ -0,0 +1,258 @@
import {
Badge,
Box,
Button,
Divider,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import {
IconAward,
IconCertificate,
IconClockHour4,
IconFileText,
IconPrinter,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { localized, STATUS_COLORS, STATUS_LABELS } from '@ema-platform/api';
export interface ExportStatementModalProps {
opened: boolean;
onClose: () => void;
displayName: string;
applications: LicenseApplication[];
licenses: IssuedLicense[];
documentsCount: number;
}
export function ExportStatementModal({
opened,
onClose,
displayName,
applications,
licenses,
documentsCount,
}: ExportStatementModalProps) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
const inProgressApps = applications.filter((a) => a.status !== 'DRAFT');
const todayStr = new Date().toLocaleDateString('en-GB', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
function handlePrint() {
window.print();
}
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
radius="md"
padding="xl"
title={
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" radius="md">
<IconCertificate size={20} />
</ThemeIcon>
<Text fw={700} size="md">
{t('dashboard.export.title')}
</Text>
</Group>
}
>
<Stack gap="lg" id="ema-export-statement-content">
{/* Statement Header */}
<Paper
p="md"
radius="md"
withBorder
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)',
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: '0.5px' }}>
{t('app.authority', 'Ethiopian Maritime Authority')}
</Text>
<Text fz={18} fw={700} mt={2}>
{displayName || t('dashboard.export.accountHolder')}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.export.subtitle')}
</Text>
</Box>
<Box style={{ textAlign: 'right' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.issueDate')}
</Text>
<Text size="sm" fw={600}>
{todayStr}
</Text>
<Badge variant="outline" color="teal" size="xs" mt={4}>
Electronic Copy
</Badge>
</Box>
</Group>
</Paper>
{/* Snapshot Summary Metrics */}
<SimpleGrid cols={{ base: 3 }} spacing="sm">
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.activeLicencesCount')}
</Text>
<Text fz={24} fw={700} c="teal">
{activeLicenses.length}
</Text>
</Paper>
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.pendingApplicationsCount')}
</Text>
<Text fz={24} fw={700} c="orange">
{inProgressApps.length}
</Text>
</Paper>
<Paper p="sm" radius="md" withBorder style={{ textAlign: 'center' }}>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.export.verifiedDocumentsCount')}
</Text>
<Text fz={24} fw={700} c="blue">
{documentsCount}
</Text>
</Paper>
</SimpleGrid>
{/* Active Licences Table */}
<Box>
<Text fw={700} size="sm" mb="xs">
{t('dashboard.sections.myLicences.title')} ({activeLicenses.length})
</Text>
{activeLicenses.length === 0 ? (
<Text size="xs" c="dimmed">
{t('applications.licences.empty')}
</Text>
) : (
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate #</Table.Th>
<Table.Th>Licence Type</Table.Th>
<Table.Th>Expiry Date</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{activeLicenses.map((lic) => (
<Table.Tr key={lic.id}>
<Table.Td fw={600}>{lic.certificateNumber}</Table.Td>
<Table.Td>{localized(lic.licenseType?.name) || '—'}</Table.Td>
<Table.Td>
{lic.expiryDate
? new Date(lic.expiryDate).toLocaleDateString('en-GB')
: '—'}
</Table.Td>
<Table.Td>
<Badge size="xs" color="teal" variant="light">
Active
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Box>
{/* Recent Applications Table */}
<Box>
<Text fw={700} size="sm" mb="xs">
{t('dashboard.sections.myApplications.title')} ({applications.slice(0, 5).length})
</Text>
{applications.length === 0 ? (
<Text size="xs" c="dimmed">
{t('dashboard.sections.myApplications.empty')}
</Text>
) : (
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Application #</Table.Th>
<Table.Th>Licence Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{applications.slice(0, 5).map((app) => (
<Table.Tr key={app.id}>
<Table.Td fw={600}>{app.applicationNumber}</Table.Td>
<Table.Td>{localized(app.licenseType?.name) || '—'}</Table.Td>
<Table.Td>
{app.createdAt
? new Date(app.createdAt).toLocaleDateString('en-GB')
: '—'}
</Table.Td>
<Table.Td>
<Badge
size="xs"
color={STATUS_COLORS[app.status] ?? 'gray'}
variant="light"
>
{STATUS_LABELS[app.status] ?? app.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Box>
<Divider />
<Text size="11px" c="dimmed" ta="center">
{t('dashboard.export.officialFooter')}
</Text>
{/* Modal Actions */}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('dashboard.export.close')}
</Button>
<Button
variant="filled"
color="emaPrimary"
leftSection={<IconPrinter size={16} />}
onClick={handlePrint}
>
{t('dashboard.export.printButton')}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,239 @@
import {
Badge,
Box,
Button,
Card,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
useComputedColorScheme,
} from '@mantine/core';
import {
IconArrowRight,
IconCheck,
IconCircleDot,
IconFolder,
IconRocket,
IconSparkles,
IconUserCheck,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export interface OnboardingChecklistCardProps {
displayName: string;
documentsCount: number;
onBrowseCatalogue: () => void;
}
export function OnboardingChecklistCard({
displayName,
documentsCount,
onBrowseCatalogue,
}: OnboardingChecklistCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const step1Complete = Boolean(displayName);
const step2Complete = documentsCount > 0;
const step3Complete = false;
const completedCount =
(step1Complete ? 1 : 0) + (step2Complete ? 1 : 0) + (step3Complete ? 1 : 0);
const progressPct = Math.round((completedCount / 3) * 100);
const steps = [
{
id: 1,
title: t('dashboard.onboarding.step1'),
desc: t('dashboard.onboarding.step1Desc'),
icon: IconUserCheck,
completed: step1Complete,
action: !step1Complete ? (
<Button
size="xs"
variant="light"
color="emaPrimary"
onClick={() => navigate('/profile')}
>
{t('common.continue')}
</Button>
) : null,
},
{
id: 2,
title: t('dashboard.onboarding.step2'),
desc: t('dashboard.onboarding.step2Desc'),
icon: IconFolder,
completed: step2Complete,
action: !step2Complete ? (
<Button
size="xs"
variant="light"
color="teal"
onClick={() => navigate('/documents')}
rightSection={<IconArrowRight size={14} />}
>
{t('dashboard.onboarding.viewVault')}
</Button>
) : null,
},
{
id: 3,
title: t('dashboard.onboarding.step3'),
desc: t('dashboard.onboarding.step3Desc'),
icon: IconRocket,
completed: step3Complete,
action: (
<Button
size="xs"
variant="filled"
color="emaPrimary"
onClick={onBrowseCatalogue}
rightSection={<IconArrowRight size={14} />}
>
{t('dashboard.onboarding.browseCatalogue')}
</Button>
),
},
];
return (
<Card
withBorder
radius="md"
padding="lg"
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-7, #1a1b1e)'
: 'var(--mantine-color-body, #ffffff)',
boxShadow: isDark
? '0 4px 20px rgba(0, 0, 0, 0.3)'
: '0 4px 16px rgba(42, 89, 172, 0.06)',
}}
>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="emaPrimary"
>
<IconSparkles size={22} />
</ThemeIcon>
<Box>
<Title order={4} fw={700}>
{t('dashboard.onboarding.title')}
</Title>
<Text size="xs" c="dimmed">
{t('dashboard.onboarding.subtitle')}
</Text>
</Box>
</Group>
<Group gap="xs">
<Badge
variant="light"
color={completedCount === 3 ? 'green' : 'blue'}
size="md"
>
{t('dashboard.onboarding.progress', {
completed: completedCount,
total: 3,
})}
</Badge>
</Group>
</Group>
<Progress
value={progressPct}
color={completedCount === 3 ? 'green' : 'emaPrimary'}
size="sm"
radius="xl"
mb="lg"
/>
<Stack gap="sm">
{steps.map((step) => {
const StepIcon = step.icon;
return (
<Paper
key={step.id}
radius="md"
p="sm"
withBorder
style={{
backgroundColor: isDark
? 'var(--mantine-color-dark-6, #25262b)'
: step.completed
? 'var(--mantine-color-gray-0, #f8f9fa)'
: '#ffffff',
borderColor: step.completed
? 'transparent'
: isDark
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)',
transition: 'all 0.15s ease',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<ThemeIcon
size={36}
radius="xl"
variant={step.completed ? 'filled' : 'light'}
color={step.completed ? 'teal' : 'gray'}
>
{step.completed ? (
<IconCheck size={18} stroke={2.5} />
) : (
<StepIcon size={18} />
)}
</ThemeIcon>
<Box>
<Group gap="xs">
<Text
size="sm"
fw={600}
style={{
textDecoration: step.completed ? 'line-through' : 'none',
opacity: step.completed ? 0.75 : 1,
}}
>
{step.title}
</Text>
{step.completed && (
<Badge size="xs" variant="dot" color="teal">
{t('dashboard.onboarding.completed')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{step.desc}
</Text>
</Box>
</Group>
{step.action && <Box>{step.action}</Box>}
</Group>
</Paper>
);
})}
</Stack>
</Card>
);
}
function Title({ order, fw, children }: { order: 1 | 2 | 3 | 4 | 5 | 6; fw: number; children: React.ReactNode }) {
return (
<Text fz={order === 4 ? 16 : 18} fw={fw} lh={1.2}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,228 @@
import { useState } from 'react';
import {
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Stack,
Text,
ThemeIcon,
UnstyledButton,
useComputedColorScheme,
} from '@mantine/core';
import {
IconBell,
IconChevronRight,
IconHeadset,
IconLifebuoy,
IconMail,
IconMapPin,
IconPhone,
IconSquarePlus,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export interface QuickActionsCardProps {
onApplyClick?: () => void;
}
export function QuickActionsCard({ onApplyClick }: QuickActionsCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [supportOpened, setSupportOpened] = useState(false);
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const actions = [
{
key: 'apply',
title: t('dashboard.quickActionsList.applyLicense'),
description: t('dashboard.quickActionsList.applyLicenseDesc'),
icon: IconSquarePlus,
color: 'emaPrimary',
onClick: () => {
if (onApplyClick) {
onApplyClick();
} else {
const el = document.getElementById('license-catalogue-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
} else {
navigate('/licensing/applications');
}
}
},
},
{
key: 'upload',
title: t('dashboard.quickActionsList.uploadDocument'),
description: t('dashboard.quickActionsList.uploadDocumentDesc'),
icon: IconUpload,
color: 'teal',
onClick: () => navigate('/documents'),
},
{
key: 'notifications',
title: t('dashboard.quickActionsList.notifications'),
description: t('dashboard.quickActionsList.notificationsDesc'),
icon: IconBell,
color: 'indigo',
onClick: () => navigate('/notifications'),
},
{
key: 'support',
title: t('dashboard.quickActionsList.support'),
description: t('dashboard.quickActionsList.supportDesc'),
icon: IconLifebuoy,
color: 'orange',
onClick: () => setSupportOpened(true),
},
];
return (
<>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="sm">
<Text fw={700} size="md">
{t('dashboard.quickActions')}
</Text>
</Group>
<Stack gap="xs">
{actions.map((item) => {
const IconComp = item.icon;
return (
<UnstyledButton
key={item.key}
onClick={item.onClick}
style={{
padding: '10px 12px',
borderRadius: '10px',
backgroundColor: isDark
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)',
transition: 'background-color 0.15s ease, transform 0.1s ease',
border: isDark
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-2)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={36}
radius="md"
variant="light"
color={item.color}
>
<IconComp size={18} stroke={1.8} />
</ThemeIcon>
<Box style={{ textAlign: 'left' }}>
<Text size="sm" fw={600} lh={1.2}>
{item.title}
</Text>
<Text size="xs" c="dimmed" lh={1.3}>
{item.description}
</Text>
</Box>
</Group>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed, #868e96)"
/>
</Group>
</UnstyledButton>
);
})}
</Stack>
</Card>
<Modal
opened={supportOpened}
onClose={() => setSupportOpened(false)}
title={
<Group gap="xs">
<ThemeIcon variant="light" color="emaPrimary" radius="md">
<IconHeadset size={18} />
</ThemeIcon>
<Text fw={700}>{t('dashboard.supportModal.title')}</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md" py="xs">
<Text size="sm" c="dimmed">
{t('dashboard.supportModal.description')}
</Text>
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="blue" radius="md">
<IconMail size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.emailLabel')}
</Text>
<Text size="sm" fw={600}>
{t('dashboard.supportModal.email')}
</Text>
</Box>
</Group>
<Divider />
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="teal" radius="md">
<IconPhone size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.phoneLabel')}
</Text>
<Text size="sm" fw={600}>
{t('dashboard.supportModal.phone')}
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.supportModal.hours')}
</Text>
</Box>
</Group>
<Divider />
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon variant="light" color="orange" radius="md">
<IconMapPin size={16} />
</ThemeIcon>
<Box>
<Text size="xs" c="dimmed" fw={600}>
{t('dashboard.supportModal.officeLabel')}
</Text>
<Text size="sm">
{t('dashboard.supportModal.office')}
</Text>
</Box>
</Group>
</Stack>
</Paper>
<Button
variant="default"
fullWidth
onClick={() => setSupportOpened(false)}
>
{t('dashboard.supportModal.close')}
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -1,4 +1,4 @@
import { Badge, Progress, Text } from '@mantine/core';
import { Badge, Box, Progress, Stack, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
@@ -9,6 +9,20 @@ import {
} from '@ema-platform/api';
import type { LicenseApplication } from '@ema-platform/api';
function formatSubmittedDate(dateStr?: string | null): string {
if (!dateStr) return '—';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return '—';
}
}
export function dashboardApplicationColumns(
t: TFunction,
): AdvancedColumn<LicenseApplication>[] {
@@ -16,40 +30,60 @@ export function dashboardApplicationColumns(
{
header: t('dashboard.table.application'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
<Box>
<Text size="sm" fw={600} lh={1.2}>
{row.original.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" mt={2}>
{row.original.companyName ?? '—'}
</Text>
</>
</Box>
),
},
{
header: t('applications.table.licence'),
header: t('dashboard.table.type'),
cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
<Text size="sm" fw={500}>
{localized(row.original.licenseType?.name) || '—'}
</Text>
),
},
{
header: t('dashboard.table.submitted'),
cell: ({ row }) => (
<Text size="xs" c="dimmed" fw={500}>
{formatSubmittedDate(row.original.createdAt)}
</Text>
),
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
{STATUS_LABELS[row.original.status]}
<Badge
variant="light"
color={STATUS_COLORS[row.original.status] ?? 'gray'}
radius="sm"
size="sm"
>
{STATUS_LABELS[row.original.status] ?? row.original.status}
</Badge>
),
},
{
header: t('applications.table.progress'),
size: 180,
size: 140,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
<Stack gap={4}>
<Progress
value={STATUS_PROGRESS[row.original.status] ?? 30}
color={STATUS_COLORS[row.original.status] ?? 'blue'}
size="xs"
radius="xl"
/>
<Text size="10px" c="dimmed" ta="right">
{STATUS_PROGRESS[row.original.status] ?? 30}%
</Text>
</Stack>
),
},
];

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -11,22 +11,28 @@ import {
Card,
Center,
Container,
Grid,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
useComputedColorScheme,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCertificate,
IconArrowRight,
IconClipboardList,
IconClockHour4,
IconCreditCard,
IconFileText,
IconShieldCheck,
IconHelpCircle,
IconPlus,
IconPrinter,
IconShip,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
import {
@@ -35,29 +41,33 @@ import {
TERMINAL_STATUSES,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyDashboardSummaryQuery,
useGetMyLicensesQuery,
useGetMyPersonalDocumentsQuery,
} from '@ema-platform/api';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
import { dashboardApplicationColumns } from './columns';
import { DashboardStats } from './DashboardStats';
import {
DashboardCharts,
StatusDonutCard,
type MonthlyTrendItem,
type StatusDistributionItem,
} from './DashboardCharts';
import { QuickActionsCard } from './QuickActionsCard';
import { OnboardingChecklistCard } from './OnboardingChecklistCard';
import { ExportStatementModal } from './ExportStatementModal';
/**
* The applicant's home screen.
*
* Ordered by what the applicant needs from it: first anything blocked on them,
* then the records they already have — licences, then applications — and only
* then the catalogue to file something new. A returning applicant comes here to
* check on their own things, not to shop; the catalogue used to sit above both
* and pushed them below the fold.
*
* The exception is an applicant with nothing at all, for whom both sections are
* empty and the catalogue *is* the page. Every figure is the signed-in user's
* own data — there are no illustrative numbers on this page.
* Days before expiry at which a licence is worth flagging when its type
* carries no renewal window of its own. The window itself is configured per
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
* what the API's `renewable` flag is computed from, so it is read off the
* licence rather than assumed here.
*/
/** Days before expiry at which a licence is worth flagging. */
const EXPIRY_WARNING_DAYS = 60;
function daysUntil(date: string): number {
@@ -79,6 +89,9 @@ function formatMoney(
export function DashboardPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [appFilter, setAppFilter] = useState<'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION'>('ALL');
const [exportOpened, setExportOpened] = useState(false);
const displayName = useSelector(
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
state.auth.user?.name?.en || state.auth.user?.username || '',
@@ -86,8 +99,9 @@ export function DashboardPage() {
const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
const { data: personalDocs } = useGetMyPersonalDocumentsQuery();
const { data: summaryData } = useGetMyDashboardSummaryQuery();
const [getCertificateUrl, { isLoading: isDownloading }] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const items = useMemo(() => applications?.items ?? [], [applications]);
@@ -100,19 +114,118 @@ export function DashboardPage() {
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
);
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
// Nothing filed and nothing held: the two "my …" sections would both be
// empty, so they collapse into one panel and the catalogue carries the page.
const hasNoRecords = items.length === 0 && heldLicenses.length === 0;
const expiringSoon = activeLicenses.filter((l) => {
const days = daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
return days >= 0 && days <= window;
});
const documentsCount = useMemo(() => {
if (!personalDocs?.slots) return 0;
return personalDocs.slots.reduce(
(sum, slot) => sum + (slot.files?.length ? slot.files.length : 0),
0,
);
}, [personalDocs]);
const approvedApplications = useMemo(() => {
const appApproved = items.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
).length;
return Math.max(appApproved, activeLicenses.length);
}, [items, activeLicenses]);
// Status distribution for donut chart
const statusDistribution = useMemo<StatusDistributionItem[]>(() => {
if (summaryData?.statusDistribution?.length) {
return summaryData.statusDistribution;
}
const approved = approvedApplications;
const inReview = inProgress.length;
const draftAndAction = needsMe.length + items.filter((a) => a.status === 'DRAFT').length;
return [
{ name: t('dashboard.charts.approved'), value: approved, color: '#12b886' },
{ name: t('dashboard.charts.underReview'), value: inReview, color: '#2a59ac' },
{ name: t('dashboard.charts.draftPending'), value: draftAndAction, color: '#fab005' },
];
}, [summaryData, approvedApplications, inProgress.length, needsMe.length, items, t]);
// Monthly timeline trend
const monthlyTrend = useMemo<MonthlyTrendItem[]>(() => {
if (summaryData?.monthlyTrend?.length) {
return summaryData.monthlyTrend;
}
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const now = new Date();
const trend: MonthlyTrendItem[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const mIdx = d.getMonth();
const yr = d.getFullYear();
const matches = items.filter((app) => {
if (!app.createdAt) return false;
const appD = new Date(app.createdAt);
return appD.getFullYear() === yr && appD.getMonth() === mIdx;
});
const submitted = matches.filter((a) => a.status !== 'DRAFT').length;
const approved = matches.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
).length;
trend.push({
month: monthNames[mIdx],
year: yr,
submitted,
approved,
});
}
return trend;
}, [summaryData, items]);
const categoryBreakdown = useMemo(() => {
if (summaryData?.categoryBreakdown?.length) {
return summaryData.categoryBreakdown;
}
const catMap: Record<string, number> = {};
items.forEach((app) => {
const cat = app.licenseType?.category || 'GENERAL';
const label = cat.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
catMap[label] = (catMap[label] || 0) + 1;
});
return Object.entries(catMap).map(([category, count]) => ({ category, count }));
}, [summaryData, items]);
// Filtered applications for recent table
const filteredApplications = useMemo(() => {
switch (appFilter) {
case 'IN_REVIEW':
return inProgress;
case 'APPROVED':
return items.filter((a) =>
['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'CERTIFICATE_ISSUED', 'COMPLETED'].includes(a.status),
);
case 'ACTION':
return needsMe;
case 'ALL':
default:
return items;
}
}, [items, inProgress, needsMe, appFilter]);
async function downloadCertificate(license: IssuedLicense) {
const result = await getCertificateUrl(license.id).unwrap();
window.open(result.url, '_blank', 'noopener');
}
function scrollToCatalogue() {
const el = document.getElementById('license-catalogue-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
}
if (isLoading) {
return <PageLoader label={t('dashboard.loading')} height={450} />;
}
@@ -120,19 +233,24 @@ export function DashboardPage() {
return (
<Container size="xl" py="lg">
<Stack gap="xl">
{/* Coastal Modern Hero Section */}
<Hero
displayName={displayName}
applicationCount={items.length}
licenseCount={activeLicenses.length}
onApplyClick={scrollToCatalogue}
onExportClick={() => setExportOpened(true)}
/>
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
{/* Profile Completion Nudge */}
<ProfileCompletionNudge />
{/* Priority Action Required */}
{needsMe.length > 0 && (
<ActionRequired applications={needsMe} navigate={navigate} />
)}
{/* Expiring Soon Notice */}
{expiringSoon.length > 0 && (
<Alert
variant="light"
@@ -154,85 +272,206 @@ export function DashboardPage() {
</Alert>
)}
<StatRow
inProgress={inProgress.length}
needsMe={needsMe.length}
{/* KPI Stat Cards */}
<DashboardStats
activeLicenses={activeLicenses.length}
expiringSoon={expiringSoon.length}
pendingApplications={inProgress.length}
approvedApplications={approvedApplications}
documentsCount={documentsCount}
/>
{hasNoRecords ? (
<GetStartedPanel />
{/* If user is brand new with 0 applications & 0 licenses, show OnboardingChecklistCard */}
{items.length === 0 && activeLicenses.length === 0 ? (
<OnboardingChecklistCard
displayName={displayName}
documentsCount={documentsCount}
onBrowseCatalogue={scrollToCatalogue}
/>
) : (
<>
<Section title={t('dashboard.sections.myLicences.title')}>
{heldLicenses.length === 0 ? (
<EmptyCard message={t('applications.licences.empty')} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
isRenewing={isRenewing}
onDownload={() => downloadCertificate(license)}
onRenew={() => renewLicense(license)}
/>
))}
</SimpleGrid>
)}
</Section>
<Section
title={t('dashboard.sections.myApplications.title')}
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
{t('common.viewAll')}
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
onRefresh={refetch}
/>
)}
</Section>
</>
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 8 }}>
<DashboardCharts
statusDistribution={statusDistribution}
monthlyTrend={monthlyTrend}
categoryBreakdown={categoryBreakdown}
totalApplications={items.length}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<StatusDonutCard
statusDistribution={statusDistribution}
totalApplications={items.length}
/>
</Grid.Col>
</Grid>
)}
<Section
title={t('dashboard.sections.apply.title')}
description={t('dashboard.sections.apply.description')}
>
<LicenseCatalogue />
</Section>
{/* Two-Column Section: Left (Applications & Licenses), Right (Actions & Support) */}
<Grid gutter="xl">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="xl">
{/* Recent Applications Card */}
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="md" wrap="wrap" gap="sm">
<Box>
<Title order={4}>{t('dashboard.sections.myApplications.title')}</Title>
<Text size="xs" c="dimmed">
{t('applications.subtitle')}
</Text>
</Box>
<Group gap="sm">
{items.length > 0 && (
<SegmentedControl
size="xs"
radius="md"
value={appFilter}
onChange={(val) => setAppFilter(val as 'ALL' | 'IN_REVIEW' | 'APPROVED' | 'ACTION')}
data={[
{ label: t('dashboard.table.all'), value: 'ALL' },
{ label: t('dashboard.table.inReview'), value: 'IN_REVIEW' },
{ label: t('dashboard.table.approved'), value: 'APPROVED' },
{ label: t('dashboard.table.actionRequired'), value: 'ACTION' },
]}
/>
)}
{items.length > 0 && (
<Anchor
size="sm"
fw={600}
onClick={() => navigate('/licensing/applications')}
>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
)}
</Group>
</Group>
{filteredApplications.length === 0 ? (
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
) : (
<ApplicationTable
applications={filteredApplications.slice(0, 6)}
navigate={navigate}
onRefresh={refetch}
/>
)}
</Card>
{/* My Licences Section */}
<Section
title={t('dashboard.sections.myLicences.title')}
action={
heldLicenses.length > 0 ? (
<Anchor
size="sm"
fw={600}
onClick={() => navigate('/licensing/applications')}
>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
) : undefined
}
>
{heldLicenses.length === 0 ? (
<EmptyCard message={t('applications.licences.empty')} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
isRenewing={isRenewing}
onDownload={() => downloadCertificate(license)}
onRenew={() => renewLicense(license)}
/>
))}
</SimpleGrid>
)}
</Section>
</Stack>
</Grid.Col>
{/* Right Column: Quick Actions & Help */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<QuickActionsCard onApplyClick={scrollToCatalogue} />
<Card withBorder radius="md" padding="lg">
<Group gap="xs" mb="xs">
<ThemeIcon variant="light" color="emaTeal" size="md" radius="md">
<IconHelpCircle size={18} />
</ThemeIcon>
<Text fw={700} size="sm">
{t('dashboard.supportModal.title')}
</Text>
</Group>
<Text size="xs" c="dimmed" lh={1.5} mb="sm">
{t('dashboard.supportModal.description')}
</Text>
<Button
variant="light"
color="emaPrimary"
size="xs"
fullWidth
onClick={() => navigate('/licensing/applications')}
>
{t('common.learnMore')}
</Button>
</Card>
</Stack>
</Grid.Col>
</Grid>
{/* License Catalogue Section */}
<Box id="license-catalogue-section" pt="md">
<Section
title={t('dashboard.sections.apply.title')}
description={t('dashboard.sections.apply.description')}
>
<LicenseCatalogue />
</Section>
</Box>
{/* Statement Export Modal */}
<ExportStatementModal
opened={exportOpened}
onClose={() => setExportOpened(false)}
displayName={displayName}
applications={items}
licenses={heldLicenses}
documentsCount={documentsCount}
/>
</Stack>
</Container>
);
}
// --------------------------------------------------------------- components
// --------------------------------------------------------------- sub-components
function Hero({
displayName,
applicationCount,
licenseCount,
onApplyClick,
onExportClick,
}: {
displayName: string;
applicationCount: number;
licenseCount: number;
onApplyClick: () => void;
onExportClick?: () => void;
}) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
const summary =
applicationCount === 0 && licenseCount === 0
? t('dashboard.hero.summaryEmpty')
@@ -244,34 +483,80 @@ function Hero({
return (
<Paper
radius="lg"
p="xl"
p={{ base: 'lg', md: 'xl' }}
style={{
background:
'linear-gradient(135deg, var(--mantine-color-emaPrimary-7) 0%, var(--mantine-color-emaPrimary-9) 55%, var(--mantine-color-emaTeal-8) 100%)',
background: isDark
? 'linear-gradient(135deg, #0e2042 0%, #153261 45%, #0d463b 100%)'
: 'linear-gradient(135deg, #18438b 0%, #2054a8 45%, #138f77 100%)',
color: 'white',
position: 'relative',
overflow: 'hidden',
boxShadow: isDark
? '0 8px 24px rgba(0, 0, 0, 0.45)'
: '0 8px 24px rgba(32, 84, 168, 0.25)',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" style={{ opacity: 0.85 }}>
{t('app.authority')}
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ zIndex: 2, maxWidth: 620 }}>
<Text size="xs" fw={600} tt="uppercase" style={{ opacity: 0.85, letterSpacing: '0.5px' }}>
{t('app.authority', 'Ethiopian Maritime Authority')}
</Text>
<Title order={2} mt={4} c="white">
<Title order={2} mt={4} c="white" fw={700} style={{ letterSpacing: '-0.3px' }}>
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
</Title>
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
<Text size="sm" mt="xs" style={{ opacity: 0.9, lineHeight: 1.55 }}>
{summary}
</Text>
<Group gap="sm" mt="md" wrap="wrap">
<Button
size="sm"
color="white"
variant="white"
c="emaPrimary.8"
leftSection={<IconPlus size={16} stroke={2.5} />}
onClick={onApplyClick}
style={{ fontWeight: 600 }}
>
{t('dashboard.quickActionsList.applyLicense')}
</Button>
{onExportClick && (
<Button
size="sm"
variant="outline"
color="white"
leftSection={<IconPrinter size={16} />}
onClick={onExportClick}
style={{
borderColor: 'rgba(255, 255, 255, 0.45)',
color: 'white',
fontWeight: 600,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
}}
>
{t('dashboard.export.button')}
</Button>
)}
</Group>
</Box>
<ThemeIcon
size={56}
radius="md"
variant="transparent"
c="white"
<Box
visibleFrom="sm"
style={{
width: 120,
height: 120,
borderRadius: 999,
backgroundColor: isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
flexShrink: 0,
}}
>
<IconShieldCheck size={44} stroke={1.3} />
</ThemeIcon>
<IconShip size={60} stroke={1.3} color="white" />
</Box>
</Group>
</Paper>
);
@@ -285,12 +570,19 @@ function ActionRequired({
navigate: (path: string) => void;
}) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme('light');
const isDark = computedColorScheme === 'dark';
return (
<Card
withBorder
radius="md"
padding="md"
style={{ backgroundColor: 'var(--mantine-color-orange-light)' }}
style={{
backgroundColor: isDark
? 'rgba(247, 103, 7, 0.12)'
: 'var(--mantine-color-orange-light)',
}}
>
<Group gap="xs" mb="sm">
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
@@ -304,7 +596,13 @@ function ActionRequired({
{applications.map((app) => {
const detail = detailFor(app, t);
return (
<Paper key={app.id} radius="sm" p="sm" withBorder>
<Paper
key={app.id}
radius="sm"
p="sm"
withBorder
style={{ backgroundColor: 'var(--mantine-color-body)' }}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
@@ -343,7 +641,6 @@ function ActionRequired({
);
}
/** What the applicant has to do next, and where that happens. */
function detailFor(
app: LicenseApplication,
t: TFunction,
@@ -385,48 +682,6 @@ function detailFor(
}
}
function StatRow({
inProgress,
needsMe,
activeLicenses,
expiringSoon,
}: {
inProgress: number;
needsMe: number;
activeLicenses: number;
expiringSoon: number;
}) {
const { t } = useTranslation();
const stats = [
{ label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
];
return (
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{stats.map((stat) => (
<Card key={stat.label} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={30} fw={700} lh={1.2} mt={4}>
{stat.value}
</Text>
</Box>
<ThemeIcon variant="light" color={stat.color} radius="md" size="lg">
<stat.icon size={18} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
);
}
function Section({
title,
description,
@@ -456,8 +711,6 @@ function Section({
);
}
function ApplicationTable({
applications,
navigate,
@@ -492,30 +745,6 @@ function ApplicationTable({
);
}
/**
* The first-visit panel, in place of two empty sections saying the same thing.
* It carries no call to action of its own — the catalogue is directly beneath
* it, and a button that only scrolls the page is noise.
*/
function GetStartedPanel() {
const { t } = useTranslation();
return (
<Card withBorder radius="md" padding="xl">
<Group gap="md" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="emaPrimary" size={44} radius="md">
<IconCertificate size={24} stroke={1.5} />
</ThemeIcon>
<Box>
<Title order={4}>{t('dashboard.getStarted.title')}</Title>
<Text size="sm" c="dimmed" mt={4} maw={620}>
{t('dashboard.getStarted.body')}
</Text>
</Box>
</Group>
</Card>
);
}
function EmptyCard({ message }: { message: string }) {
return (
<Card withBorder radius="md" padding="xl">

View File

@@ -490,9 +490,16 @@ export function LicenseApplicationPage() {
// underneath them.
const editableWhileSubmitted =
application.status === "SUBMITTED" && !application.assignedOfficerId;
// A draft of a type EMA has since stopped offering is frozen: the server
// refuses every edit and the submit (`assertTypeOpen`), so the form is shown
// read-only rather than letting the applicant fill it in and fail at the
// end. Anything already submitted keeps moving and is not affected.
const typeClosed =
application.status === "DRAFT" && config.licenseType.isActive === false;
const readOnly =
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
!editableWhileSubmitted;
typeClosed ||
(!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
!editableWhileSubmitted);
// A DRAFT has nothing worth summarising yet, so it always opens straight
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
// to the summary first.
@@ -828,6 +835,24 @@ export function LicenseApplicationPage() {
</Alert>
)}
{typeClosed && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t(
"licenseApplication.typeClosedTitle",
"This service is no longer offered",
)}
mb="md"
>
{t(
"licenseApplication.typeClosed",
"EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.",
{ name: localized(config.licenseType.name) },
)}
</Alert>
)}
{config.licenseType.requiresIssuanceScheduling &&
(application.status === "PAYMENT_CONFIRMED" ||
application.status === "SCHEDULED") && (

View File

@@ -98,6 +98,9 @@ export function applicationActionsColumn(
deps.can([PORTAL_PERMISSIONS.INITIATE_PAYMENT])) && (
<Button
size="xs"
// The server freezes a draft of a type that has been closed, so
// there is nothing to continue into; discarding stays available.
disabled={app.status === 'DRAFT' && app.licenseType?.isActive === false}
loading={deps.isPaying && app.status === 'PAYMENT_PENDING'}
variant={
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
@@ -116,7 +119,9 @@ export function applicationActionsColumn(
}
>
{app.status === 'DRAFT'
? t('applications.actions.continue')
? app.licenseType?.isActive === false
? t('applications.actions.closed')
: t('applications.actions.continue')
: app.status === 'RESUBMIT_REQUIRED'
? t('applications.actions.fixResubmit')
: app.status === 'PAYMENT_PENDING'

View File

@@ -64,7 +64,13 @@ import { applicationColumns } from './columns';
import { applicationActionsColumn } from './actions';
import classes from '../MyApplicationsPage.module.css';
/** Days before expiry at which a licence is worth flagging. */
/**
* Days before expiry at which a licence is worth flagging when its type
* carries no renewal window of its own. The window itself is configured per
* licence type on the backoffice Behaviour tab (`renewalWindowDays`) and is
* what the API's `renewable` flag is computed from, so it is read off the
* licence rather than assumed here.
*/
const EXPIRY_WARNING_DAYS = 60;
function daysUntil(date: string): number {
@@ -240,7 +246,8 @@ export function MyApplicationsPage() {
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
const expiringSoon = activeLicences.filter((l) => {
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
const window = l.licenseType?.renewalWindowDays || EXPIRY_WARNING_DAYS;
return days >= 0 && days <= window;
});
const [search, setSearch] = useState('');

View File

@@ -49,7 +49,9 @@ import { useApplicationPayment } from "../../payments/hooks/useApplicationPaymen
* The stages a document passes through, for the progress stepper. Derived
* from the status the API moves, never stored separately.
*/
const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
type Stage = { label: string; statuses: SeafarerDocumentStatus[] };
const STAGES: Stage[] = [
{ label: "Requested", statuses: ["AWAITING_REGISTRATION"] },
{ label: "Payment", statuses: ["PAYMENT_PENDING"] },
{ label: "Paid", statuses: ["PAID", "PAYMENT_CONFIRMED"] },
@@ -57,9 +59,26 @@ const STAGES: { label: string; statuses: SeafarerDocumentStatus[] }[] = [
{ label: "Issued", statuses: ["ISSUED"] },
];
function stageIndexFor(status: SeafarerDocumentStatus): number {
/**
* Whether this document is handed over at the counter. Configured per type
* on the backoffice Behaviour tab (`requiresIssuanceScheduling`); an older
* server that does not report it is treated as the collected-in-person
* default the documents always had.
*/
function collectedInPerson(document: SeafarerDocument): boolean {
return document.requiresIssuanceScheduling ?? true;
}
/** The stepper for one document: no pickup step for a type issued on payment. */
function stagesFor(document: SeafarerDocument): Stage[] {
return collectedInPerson(document)
? STAGES
: STAGES.filter((stage) => stage.label !== "Pickup Scheduled");
}
function stageIndexFor(stages: Stage[], status: SeafarerDocumentStatus): number {
let reached = -1;
STAGES.forEach((stage, i) => {
stages.forEach((stage, i) => {
if (stage.statuses.includes(status)) reached = i;
});
return reached;
@@ -82,7 +101,12 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status);
const stages = stagesFor(document);
const activeStep = stageIndexFor(stages, document.status);
// Offered inside the type's configured renewal window, and only where the
// type allows it — decided by the API, not re-derived from a date here.
const renewable = document.renewable ?? true;
const reissuable = document.reissuable ?? true;
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
try {
@@ -133,7 +157,7 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
{stages.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
@@ -171,7 +195,9 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
)}
{(document.status === "PAID" || document.status === "PAYMENT_CONFIRMED") && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />} mt="md">
Payment received. The Authority will schedule a date for you to collect your {title}.
{collectedInPerson(document)
? `Payment received. The Authority will schedule a date for you to collect your ${title}.`
: `Payment received. Your ${title} is being issued.`}
</Alert>
)}
{document.status === "SCHEDULED" && document.scheduledIssuanceDate && (
@@ -193,24 +219,28 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconRefresh size={14} />}
loading={renewing}
onClick={() => renewOrReplace(() => renew(document.id))}
>
Renew
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconReplace size={14} />}
loading={replacing}
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
{renewable && (
<Button
size="xs"
variant="default"
leftSection={<IconRefresh size={14} />}
loading={renewing}
onClick={() => renewOrReplace(() => renew(document.id))}
>
Renew
</Button>
)}
{reissuable && (
<Button
size="xs"
variant="default"
leftSection={<IconReplace size={14} />}
loading={replacing}
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
)}
</Group>
</Group>
</Alert>

View File

@@ -151,6 +151,79 @@ export const am: Translations = {
},
stats: {
expiringSoon: 'በቅርቡ የሚያበቃ',
activeLicenses: 'ንቁ ፍቃዶች',
pendingApplications: 'በመጠባበቅ ላይ ያሉ ማመልከቻዎች',
approved: 'የጸደቁ',
documents: 'ሰነዶች',
upToDate: 'የተሟሉ',
allInGoodStanding: 'ንቁ እና ህጋዊ',
inReview: 'በግምገማ ላይ',
passRate: 'ዝግጁ / የተሰጠ',
},
charts: {
statusTitle: 'የማመልከቻ ሁኔታ',
activityTitle: 'የማመልከቻ እንቅስቃሴ',
activitySubtitle: 'የወርሃዊ ማመልከቻዎች እና ማጽደቆች',
viewTimeline: 'የጊዜ ሰሌዳ',
viewCategory: 'በአገልግሎት አይነት',
categoryTitle: 'ማመልከቻዎች በአገልግሎት ዘርፍ',
categoryCount: 'ማመልከቻዎች',
approved: 'የጸደቀ',
underReview: 'በግምገማ ላይ',
draftPending: 'ረቂቅ / በመጠባበቅ ላይ',
total: 'ጠቅላላ',
submitted: 'የገቡ',
noData: 'እስካሁን ምንም እንቅስቃሴ አልተመዘገበም',
},
onboarding: {
title: 'የኢ.ባ.ባ ፖርታል ጅምር መመሪያ',
subtitle: 'የተረጋገጠ ፍቃድ ለማግኘት እነዚህን 3 ቀላል ደረጃዎች ያጠናቅቁ።',
progress: 'ከ{{total}} ውስጥ {{completed}} ተጠናቋል',
step1: 'የመገለጫ ዝርዝሮችን ያረጋግጡ',
step1Desc: 'የእውቂያ መረጃ እና ምዝገባ ዝርዝሮች',
step2: 'ሰነዶችን ወደ ቮልት ይስቀሉ',
step2Desc: 'ፓስፖርት፣ መታወቂያ ወይም የምስክር ወረቀቶችን ይስቀሉ',
step3: 'የመጀመሪያውን ፍቃድ ያመልክቱ',
step3Desc: 'ካታሎጉን ያስሱ እና ማመልከቻዎን ያስገቡ',
viewVault: 'ቮልት ክፈት',
browseCatalogue: 'ካታሎግ አስስ',
completed: 'ተጠናቋል',
},
export: {
button: 'የፍቃዶች መግለጫ አውርድ',
title: 'ኦፊሴላዊ የፍቃድ መግለጫ',
subtitle: 'የተሰጡ ፍቃዶች እና በማመልከት ላይ ያሉ ማመልከቻዎች መዝገብ',
accountHolder: 'የመለያው ባለቤት',
issueDate: 'የወጣበት ቀን',
activeLicencesCount: 'ንቁ ፍቃዶች',
pendingApplicationsCount: 'በግምገማ ላይ',
verifiedDocumentsCount: 'የተረጋገጡ ሰነዶች',
printButton: 'አትም / ፒዲኤፍ አስቀምጥ',
close: 'ዝጋ',
officialFooter: 'በኢትዮጵያ የባህር ባለስልጣን በኤሌክትሮኒክ የተሰጠ። ለማረጋገጥ licensing@ema.gov.et ያግኙ።',
},
quickActionsList: {
applyLicense: 'ለአዲስ ፍቃድ ያመልክቱ',
applyLicenseDesc: 'ካታሎግ ያስሱ እና ማመልከቻ ይጀምሩ',
uploadDocument: 'ሰነድ ይስቀሉ',
uploadDocumentDesc: 'የግል መገለጫ ቮልት ሰነዶችን ያስተዳድሩ',
notifications: 'ማሳወቂያዎች',
notificationsDesc: 'የቅርብ ጊዜ ዝመናዎችን ያረጋግጡ',
support: 'ድጋፍ ያግኙ',
supportDesc: 'ጥያቄዎች እና ቴክኒካዊ ድጋፍ',
},
supportModal: {
title: 'የኢ.ባ.ባ ድጋፍ እና እገዛ',
description: 'በማመልከቻዎ ወይም በፍቃድዎ ላይ እርዳታ ይፈልጋሉ? የፍቃድ ድጋፍ ቡድናችን በስራ ሰዓታት ውስጥ ይገኛል።',
emailLabel: 'የኢሜይል ድጋፍ',
email: 'licensing@ema.gov.et',
phoneLabel: 'የስልክ መስመር',
phone: '+251 11 551 8855',
hoursLabel: 'የስራ ሰዓት',
hours: 'ሰኞ አርብ፣ 2:30 ጠዋት 11:30 ከሰዓት',
officeLabel: 'ዋና መስሪያ ቤት',
office: 'የኢትዮጵያ የባህር ባለስልጣን፣ አዲስ አበባ፣ ኢትዮጵያ',
close: 'ዝጋ',
},
sections: {
myLicences: {
@@ -171,6 +244,13 @@ export const am: Translations = {
},
table: {
application: 'ማመልከቻ',
submitted: 'የገባበት ቀን',
type: 'አይነት',
status: 'ሁኔታ',
all: 'ሁሉም',
inReview: 'በግምገማ ላይ',
approved: 'የጸደቀ',
actionRequired: 'እርምጃ የሚያስፈልገው',
},
},
@@ -223,6 +303,7 @@ export const am: Translations = {
},
actions: {
continue: 'ቀጥል',
closed: 'አገልግሎቱ ቆሟል',
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
pay: '{{amount}} {{currency}} ክፈል',
certificate: 'የምስክር ወረቀት',
@@ -953,6 +1034,9 @@ export const am: Translations = {
},
licenseApplication: {
typeClosedTitle: 'ይህ አገልግሎት ከአሁን በኋላ አይሰጥም',
typeClosed:
'የ{{name}} አገልግሎት መሰጠቱ ቆሟል። ይህ ረቂቅ ከአሁን በኋላ ሊጠናቀቅ ወይም ሊገባ አይችልም፤ ከማመልከቻዎች ዝርዝርዎ ማጥፋት ይችላሉ።',
loading: 'ማመልከቻ በመጫን ላይ…',
fee: 'ክፍያ፡ {{amount}} {{currency}}',
review: 'ግምገማ',

View File

@@ -151,6 +151,79 @@ export const en = {
},
stats: {
expiringSoon: 'Expiring soon',
activeLicenses: 'Active Licenses',
pendingApplications: 'Pending Applications',
approved: 'Approved',
documents: 'Documents',
upToDate: 'Up to date',
allInGoodStanding: 'Active & valid',
inReview: 'In review',
passRate: 'Ready / Issued',
},
charts: {
statusTitle: 'Application Status',
activityTitle: 'Application Activity',
activitySubtitle: 'Monthly filings & approvals',
viewTimeline: 'Timeline',
viewCategory: 'By Category',
categoryTitle: 'Applications by Service',
categoryCount: 'Applications',
approved: 'Approved',
underReview: 'Under review',
draftPending: 'Draft / pending',
total: 'Total',
submitted: 'Submitted',
noData: 'No activity recorded yet',
},
onboarding: {
title: 'Getting Started with EMA Portal',
subtitle: 'Complete these 3 simple steps to get certified and verified.',
progress: '{{completed}} of {{total}} completed',
step1: 'Verify Profile Details',
step1Desc: 'Contact info and account registration',
step2: 'Upload Documents to Vault',
step2Desc: 'Upload passport, ID or certifications for reusable verification',
step3: 'Apply for First License',
step3Desc: 'Browse catalogue and submit your first application',
viewVault: 'Open Vault',
browseCatalogue: 'Browse Catalogue',
completed: 'Completed',
},
export: {
button: 'Export Statement',
title: 'Official Licensing Statement',
subtitle: 'Official record of issued licences and pending applications',
accountHolder: 'Account Holder',
issueDate: 'Statement Date',
activeLicencesCount: 'Active Licences',
pendingApplicationsCount: 'In Review',
verifiedDocumentsCount: 'Vault Documents',
printButton: 'Print / Save as PDF',
close: 'Close',
officialFooter: 'Issued electronically by the Ethiopian Maritime Authority. For validation, contact licensing@ema.gov.et.',
},
quickActionsList: {
applyLicense: 'Apply for new license',
applyLicenseDesc: 'Explore catalogue and start application',
uploadDocument: 'Upload a document',
uploadDocumentDesc: 'Manage verified profile vault documents',
notifications: 'Notifications',
notificationsDesc: 'Check recent authority updates',
support: 'Contact support',
supportDesc: 'Inquiries & technical assistance',
},
supportModal: {
title: 'EMA Support & Assistance',
description: 'Need help with your application or credentials? Our licensing support team is available during working hours.',
emailLabel: 'Email Support',
email: 'licensing@ema.gov.et',
phoneLabel: 'Helpline',
phone: '+251 11 551 8855',
hoursLabel: 'Working Hours',
hours: 'Mon Fri, 8:30 AM 5:30 PM (EAT)',
officeLabel: 'Headquarters',
office: 'Ethiopian Maritime Authority, Addis Ababa, Ethiopia',
close: 'Close',
},
sections: {
myLicences: {
@@ -171,6 +244,13 @@ export const en = {
},
table: {
application: 'Application',
submitted: 'Submitted',
type: 'Type',
status: 'Status',
all: 'All',
inReview: 'In Review',
approved: 'Approved',
actionRequired: 'Action Required',
},
},
@@ -223,6 +303,7 @@ export const en = {
},
actions: {
continue: 'Continue',
closed: 'No longer offered',
fixResubmit: 'Fix & resubmit',
pay: 'Pay {{amount}} {{currency}}',
certificate: 'Certificate',
@@ -959,6 +1040,9 @@ export const en = {
},
licenseApplication: {
typeClosedTitle: 'This service is no longer offered',
typeClosed:
'EMA has stopped offering {{name}}. This draft can no longer be completed or submitted; you can discard it from your applications list.',
loading: 'Loading Application…',
fee: 'Fee: {{amount}} {{currency}}',
review: 'Review',

View File

@@ -269,7 +269,7 @@ export const router = createBrowserRouter([
path: "/basic-training-certificate",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<SeamanBookPage />
<SeamanBookPage service="BTC" />
</RequirePermission>
),
},

View File

@@ -13,9 +13,10 @@ export default defineConfig({
port: 4200,
host: "localhost",
proxy: {
'/api': {
target: 'https://ema-api-dev.triaplc.com',
"/api": {
target: process.env.VITE_API_PROXY_TARGET || "https://ema-api-dev.triaplc.com",
changeOrigin: true,
secure: false,
},
},
},