mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 15:25:47 +00:00
feat: implement comprehensive dashboard UI components, analytics integration, and internationalization support for portal and backoffice apps.
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconChartPie,
|
||||
IconShieldCheck,
|
||||
IconTrendingUp,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { AdminDashboardAnalytics } from '@ema-platform/api';
|
||||
|
||||
const CHART_HEIGHT = 280;
|
||||
const AXIS_STYLE = { fontSize: 11, fill: 'var(--mantine-color-dimmed)' } as const;
|
||||
const GRID_COLOR = 'var(--mantine-color-default-border)';
|
||||
|
||||
const TOOLTIP_BOX_STYLE = {
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
fontSize: 12,
|
||||
} as const;
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
badge,
|
||||
children,
|
||||
empty,
|
||||
emptyText,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: typeof IconTrendingUp;
|
||||
badge?: ReactNode;
|
||||
children: ReactNode;
|
||||
empty?: boolean;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="lg" p="lg" h="100%">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{Icon && (
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="blue">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<div>
|
||||
<Text fw={600} size="sm" lh={1.3}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{badge}
|
||||
</Group>
|
||||
|
||||
{empty ? (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{emptyText ?? 'No data available for this chart yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Box w="100%" h={CHART_HEIGHT}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{children as never}
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const CATEGORY_NAMES: Record<string, string> = {
|
||||
CARGO_FREIGHT: 'Cargo & Freight',
|
||||
SHIPPING_AGENCY: 'Shipping Agency',
|
||||
MARITIME_PERSONNEL: 'Maritime Personnel & CoC',
|
||||
VESSEL_SERVICES: 'Vessel Registry',
|
||||
INVESTMENT: 'Investment Licences',
|
||||
WAIVER_SERVICES: 'Waiver Services',
|
||||
LOGISTICS_SERVICES: 'Maritime Logistics',
|
||||
OTHER: 'Other Services',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
'#228be6',
|
||||
'#12b886',
|
||||
'#7950f2',
|
||||
'#fd7e14',
|
||||
'#fab005',
|
||||
'#fa5252',
|
||||
'#15aabf',
|
||||
'#868e96',
|
||||
];
|
||||
|
||||
interface DashboardChartsProps {
|
||||
analytics?: AdminDashboardAnalytics | null;
|
||||
periodLabel?: string;
|
||||
fallbackTrend?: Array<{
|
||||
month: string;
|
||||
submitted: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}>;
|
||||
fallbackStatusDistribution?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function DashboardCharts({
|
||||
analytics,
|
||||
periodLabel = 'Last 6 Months',
|
||||
fallbackTrend,
|
||||
fallbackStatusDistribution,
|
||||
}: DashboardChartsProps) {
|
||||
const trendData =
|
||||
analytics?.monthlyTrend && analytics.monthlyTrend.length > 0
|
||||
? analytics.monthlyTrend
|
||||
: fallbackTrend ?? [];
|
||||
|
||||
const statusData =
|
||||
analytics?.statusDistribution && analytics.statusDistribution.length > 0
|
||||
? analytics.statusDistribution.filter((s) => s.value > 0)
|
||||
: (fallbackStatusDistribution ?? []).filter((s) => s.value > 0);
|
||||
|
||||
const categoryData = (analytics?.categoryBreakdown ?? [])
|
||||
.map((item, idx) => ({
|
||||
name: CATEGORY_NAMES[item.category] ?? item.category.replace(/_/g, ' '),
|
||||
count: item.count,
|
||||
color: CATEGORY_COLORS[idx % CATEGORY_COLORS.length],
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
const officerData = (analytics?.officerWorkload ?? []).slice(0, 8);
|
||||
|
||||
const slaRate = analytics?.kpis?.slaComplianceRate ?? 100;
|
||||
const overdueCount = analytics?.kpis?.overdueSla ?? 0;
|
||||
const withinSlaCount = analytics?.kpis?.withinSla ?? 0;
|
||||
|
||||
const slaToneColor =
|
||||
slaRate >= 90 ? 'teal' : slaRate >= 75 ? 'yellow' : 'red';
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
{/* Chart 1: Application Intake & Decisions Trend */}
|
||||
<ChartCard
|
||||
title="Intake & Decisions Trend"
|
||||
subtitle="Trajectory of incoming applications vs approved & issued certificates"
|
||||
icon={IconTrendingUp}
|
||||
badge={
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
}
|
||||
empty={trendData.length === 0}
|
||||
>
|
||||
<AreaChart
|
||||
data={trendData}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="submittedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="approvedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#12b886" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#12b886" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
|
||||
<XAxis dataKey="month" tick={AXIS_STYLE} tickLine={false} />
|
||||
<YAxis tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_BOX_STYLE}
|
||||
formatter={(val: number, name: string) => [
|
||||
val,
|
||||
name === 'submitted'
|
||||
? 'Submitted'
|
||||
: name === 'approved'
|
||||
? 'Approved / Issued'
|
||||
: 'Rejected',
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
wrapperStyle={{ fontSize: 12, paddingBottom: 8 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="submitted"
|
||||
name="Submitted"
|
||||
stroke="#339af0"
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill="url(#submittedGrad)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="approved"
|
||||
name="Approved"
|
||||
stroke="#12b886"
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill="url(#approvedGrad)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartCard>
|
||||
|
||||
{/* Chart 2: Pipeline Distribution Donut */}
|
||||
<ChartCard
|
||||
title="Pipeline Status Distribution"
|
||||
subtitle="Proportion of applications by workflow state"
|
||||
icon={IconChartPie}
|
||||
badge={
|
||||
<Badge variant="light" color="indigo" size="sm">
|
||||
Live Workload
|
||||
</Badge>
|
||||
}
|
||||
empty={statusData.length === 0}
|
||||
>
|
||||
<PieChart margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_BOX_STYLE}
|
||||
formatter={(val: number, name: string) => [`${val} applications`, name]}
|
||||
/>
|
||||
<Legend
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
iconType="circle"
|
||||
wrapperStyle={{ fontSize: 12, lineHeight: '22px' }}
|
||||
/>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="40%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={95}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartCard>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
{/* Chart 3: Category Workload Breakdown */}
|
||||
<ChartCard
|
||||
title="Applications by License Category"
|
||||
subtitle="Top operational categories handled by the Authority"
|
||||
icon={IconChartBar}
|
||||
empty={categoryData.length === 0}
|
||||
>
|
||||
<BarChart
|
||||
data={categoryData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} horizontal={false} />
|
||||
<XAxis type="number" tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={140}
|
||||
tick={AXIS_STYLE}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_BOX_STYLE}
|
||||
formatter={(val: number) => [`${val} applications`, 'Volume']}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]} maxBarSize={22}>
|
||||
{categoryData.map((entry, index) => (
|
||||
<Cell key={`cat-cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
|
||||
{/* Chart 4: Service Level Agreement (SLA) Health */}
|
||||
<Card withBorder radius="lg" p="lg" h="100%">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color={slaToneColor}>
|
||||
<IconShieldCheck size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="sm" lh={1.3}>
|
||||
SLA Health & Queue Turnaround
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Turnaround compliance against published authority SLAs
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge variant="light" color={slaToneColor} size="sm">
|
||||
{slaRate}% On-Time
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Center py="xs">
|
||||
<RingProgress
|
||||
size={170}
|
||||
thickness={16}
|
||||
roundCaps
|
||||
sections={[
|
||||
{ value: slaRate, color: slaToneColor },
|
||||
{ value: 100 - slaRate, color: 'gray.2' },
|
||||
]}
|
||||
label={
|
||||
<Center>
|
||||
<Stack align="center" gap={0}>
|
||||
<Text fz={26} fw={800} lh={1}>
|
||||
{slaRate}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} mt={4}>
|
||||
Compliance
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
}
|
||||
/>
|
||||
</Center>
|
||||
|
||||
<SimpleGrid cols={2} spacing="md" mt="sm">
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-teal-light)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconClock size={16} color="var(--mantine-color-teal-filled)" />
|
||||
<Text size="xs" fw={600} c="teal">
|
||||
Within SLA
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="teal" mt={4}>
|
||||
{withinSlaCount}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Applications on schedule
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
backgroundColor:
|
||||
overdueCount > 0
|
||||
? 'var(--mantine-color-red-light)'
|
||||
: 'var(--mantine-color-gray-light)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconAlertTriangle
|
||||
size={16}
|
||||
color={
|
||||
overdueCount > 0
|
||||
? 'var(--mantine-color-red-filled)'
|
||||
: 'var(--mantine-color-dimmed)'
|
||||
}
|
||||
/>
|
||||
<Text size="xs" fw={600} c={overdueCount > 0 ? 'red' : 'dimmed'}>
|
||||
SLA Breached
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c={overdueCount > 0 ? 'red' : 'dimmed'} mt={4}>
|
||||
{overdueCount}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Require priority action
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Chart 5: Officer Workload Distribution */}
|
||||
{officerData.length > 0 && (
|
||||
<ChartCard
|
||||
title="Reviewer & Queue Workload Distribution"
|
||||
subtitle="Active cases held by individual officers and the unclaimed pool, segmented by SLA status"
|
||||
icon={IconUsers}
|
||||
badge={
|
||||
<Badge variant="light" color="indigo" size="sm">
|
||||
Staff Capacity
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<BarChart
|
||||
data={officerData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 20, left: 15, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} horizontal={false} />
|
||||
<XAxis type="number" tick={AXIS_STYLE} tickLine={false} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="officerName"
|
||||
width={160}
|
||||
tick={AXIS_STYLE}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_BOX_STYLE}
|
||||
formatter={(val: number, name: string) => [
|
||||
`${val} applications`,
|
||||
name === 'onScheduleCount' ? 'On Schedule' : 'SLA Overdue',
|
||||
]}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
wrapperStyle={{ fontSize: 12, paddingBottom: 6 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="onScheduleCount"
|
||||
name="On Schedule"
|
||||
stackId="workload"
|
||||
fill="#228be6"
|
||||
radius={[0, 0, 0, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="overdueCount"
|
||||
name="SLA Overdue"
|
||||
stackId="workload"
|
||||
fill="#fa5252"
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartCard>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import { Badge, Group, Text } from '@mantine/core';
|
||||
import { WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -9,23 +9,55 @@ import {
|
||||
|
||||
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
header: 'Number',
|
||||
header: 'Application #',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
<Text size="sm" fw={600} c="blue">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Company',
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
header: 'Company / Applicant',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{row.original.companyName || row.original.tradeName || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'License Type',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{row.original.licenseType?.name?.en ?? 'Maritime Service'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Waiting Since',
|
||||
cell: ({ row }) => (
|
||||
<WaitingFor
|
||||
since={row.original.submittedAt ?? row.original.createdAt}
|
||||
slaDays={
|
||||
row.original.licenseType?.slaHours
|
||||
? row.original.licenseType.slaHours / 24
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
|
||||
{STATUS_LABELS[row.original.status as LicenseStatus]}
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status as LicenseStatus;
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={STATUS_COLORS[status] ?? 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCash,
|
||||
IconCertificate,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClockExclamation,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconFolders,
|
||||
IconInbox,
|
||||
IconRefresh,
|
||||
IconShip,
|
||||
IconUserCheck,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
useGetAdminDashboardAnalyticsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
useGetVesselsQuery,
|
||||
useListSeafarerDocumentsQuery,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import {
|
||||
AdvancedTable,
|
||||
@@ -20,178 +54,797 @@ import {
|
||||
PageLoader,
|
||||
StatTile,
|
||||
WaitingFor,
|
||||
notify,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
import { DashboardCharts } from './DashboardCharts';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Every figure here is counted from a queue the officer can open, and each
|
||||
* tile navigates to the list it counted — a dashboard that cannot be drilled
|
||||
* into is a poster. Nothing is charted: the platform exposes queues, not time
|
||||
* series, and an earlier version of this page invented both a registration
|
||||
* trend and a staff-role breakdown rather than admit that.
|
||||
*
|
||||
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
|
||||
* queues are permission-gated and an officer without them simply gets no
|
||||
* count — never a broken page — so the tiles read `—` rather than `0`, which
|
||||
* would be a lie.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<string | null>('unclaimed');
|
||||
const [period, setPeriod] = useState<string>('6m');
|
||||
|
||||
// Primary platform analytics query with time window parameter
|
||||
const analyticsQuery = useGetAdminDashboardAnalyticsQuery({ period });
|
||||
|
||||
// Review queues (for tables and fallback counts)
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
|
||||
// Department queues
|
||||
const registrations = useListSeafarerRegistrationsQuery({
|
||||
status: 'SUBMITTED',
|
||||
take: 1,
|
||||
});
|
||||
const seamanBooks = useListSeafarerDocumentsQuery({
|
||||
kind: 'SEAMAN_BOOK',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
const btcDocuments = useListSeafarerDocumentsQuery({
|
||||
kind: 'BASIC_TRAINING',
|
||||
status: 'PAYMENT_PENDING',
|
||||
take: 1,
|
||||
});
|
||||
const vesselsQuery = useGetVesselsQuery({ search: '' });
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
|
||||
}
|
||||
const analytics = analyticsQuery.data;
|
||||
|
||||
// Real data arrays from queries
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
const allInFlight = [...unclaimed, ...inProgress];
|
||||
|
||||
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
|
||||
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
|
||||
// Fallback counts if analytics is loading
|
||||
const fallbackNeedsApplicant = allInFlight.filter(
|
||||
(a) => a.status === 'RESUBMIT_REQUIRED',
|
||||
).length;
|
||||
const fallbackAwaitingPayment = allInFlight.filter(
|
||||
(a) => a.status === 'PAYMENT_PENDING',
|
||||
).length;
|
||||
|
||||
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
|
||||
const byAge = [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
const paged = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
|
||||
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
|
||||
q.isError ? undefined : q.data?.total;
|
||||
|
||||
const show = (n: number | undefined) => (n === undefined ? '—' : n);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Work waiting across the Authority's review queues."
|
||||
noMargin
|
||||
/>
|
||||
// KPIs merging backend analytics with client queries for instant accuracy
|
||||
const unclaimedCount = analytics?.kpis.unclaimedQueue ?? unclaimed.length;
|
||||
const assignedToMeCount = analytics?.kpis.assignedToMe ?? inProgress.length;
|
||||
const inProgressTotalCount =
|
||||
analytics?.kpis.inProgressTotal ?? inProgress.length;
|
||||
const needsApplicantCount =
|
||||
analytics?.kpis.needsApplicant ?? fallbackNeedsApplicant;
|
||||
const awaitingPaymentCount =
|
||||
analytics?.kpis.awaitingPayment ?? fallbackAwaitingPayment;
|
||||
const overdueSlaCount = analytics?.kpis.overdueSla ?? 0;
|
||||
const activeLicensesCount = analytics?.kpis.activeLicenses ?? 0;
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
// Department counts
|
||||
const seafarerRegCount =
|
||||
analytics?.departmentSummary.seafarerRegistrations.submitted ??
|
||||
countOf(registrations);
|
||||
const seamanBookCount =
|
||||
analytics?.departmentSummary.seamanBooks.pending ?? countOf(seamanBooks);
|
||||
const btcCount =
|
||||
analytics?.departmentSummary.btc.pending ?? countOf(btcDocuments);
|
||||
const vesselCount =
|
||||
analytics?.departmentSummary.vessels.total ?? countOf(vesselsQuery);
|
||||
|
||||
// Revenue display
|
||||
const totalRevenue = analytics?.kpis.revenue.totalCollected ?? 0;
|
||||
const formattedRevenue =
|
||||
totalRevenue > 0
|
||||
? `${totalRevenue.toLocaleString('en-US')} ETB`
|
||||
: 'Active';
|
||||
|
||||
// Oldest first sorting for unclaimed worklist
|
||||
const byAge = useMemo(() => {
|
||||
return [...unclaimed].sort((a, b) =>
|
||||
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
|
||||
);
|
||||
}, [unclaimed]);
|
||||
|
||||
const pagedUnclaimed = table.paginate(byAge.slice(0, 8));
|
||||
|
||||
// Urgent SLA applications list
|
||||
const urgentApps = analytics?.urgentApplications ?? [];
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
analyticsQuery.refetch();
|
||||
queue.refetch();
|
||||
mine.refetch();
|
||||
registrations.refetch();
|
||||
seamanBooks.refetch();
|
||||
notify.success('Dashboard metrics refreshed.');
|
||||
};
|
||||
|
||||
const periodLabelMap: Record<string, string> = {
|
||||
'7d': 'Last 7 Days',
|
||||
'30d': 'Last 30 Days',
|
||||
'6m': 'Last 6 Months',
|
||||
'1y': 'Last 1 Year',
|
||||
};
|
||||
|
||||
// CSV Report Generator
|
||||
const handleExportCsv = () => {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('ETHIOPIAN MARITIME AUTHORITY - OPERATIONS DASHBOARD REPORT');
|
||||
lines.push(`Generated At,${new Date().toISOString()}`);
|
||||
lines.push(`Time Window,${periodLabelMap[period] ?? period}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Executive KPIs
|
||||
lines.push('--- EXECUTIVE KPIS ---');
|
||||
lines.push('Metric,Value');
|
||||
lines.push(`Awaiting Claim (Unclaimed Pool),${unclaimedCount}`);
|
||||
lines.push(`Assigned to Me,${assignedToMeCount}`);
|
||||
lines.push(`In Review (All Staff),${inProgressTotalCount}`);
|
||||
lines.push(`Action Required (Applicant Edits),${needsApplicantCount}`);
|
||||
lines.push(`Awaiting Payment,${awaitingPaymentCount}`);
|
||||
lines.push(`SLA Overdue,${overdueSlaCount}`);
|
||||
lines.push(`SLA Compliance Rate,${analytics?.kpis.slaComplianceRate ?? 100}%`);
|
||||
lines.push(`Active Registered Licences,${activeLicensesCount}`);
|
||||
lines.push(`Total Settled Revenue (ETB),${totalRevenue}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 2: Department Summary
|
||||
lines.push('--- DEPARTMENT WORKLOADS ---');
|
||||
lines.push('Department / Service,Pending Count,Total Count');
|
||||
lines.push(
|
||||
`Seafarer Registrations,${seafarerRegCount ?? 0},${analytics?.departmentSummary.seafarerRegistrations.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Seaman Books,${seamanBookCount ?? 0},${analytics?.departmentSummary.seamanBooks.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Basic Training (BTC),${btcCount ?? 0},${analytics?.departmentSummary.btc.total ?? 0}`,
|
||||
);
|
||||
lines.push(
|
||||
`Vessel Registry,${analytics?.departmentSummary.vessels.pending ?? 0},${vesselCount ?? 0}`,
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// Section 3: Time Series
|
||||
if (analytics?.monthlyTrend && analytics.monthlyTrend.length > 0) {
|
||||
lines.push('--- INTAKE & DECISIONS TREND ---');
|
||||
lines.push('Period,Submitted,Approved / Issued,Rejected,Revenue (ETB)');
|
||||
for (const row of analytics.monthlyTrend) {
|
||||
lines.push(
|
||||
`"${row.month} ${row.year}",${row.submitted},${row.approved},${row.rejected},${row.revenue}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Section 4: Category Breakdown
|
||||
if (analytics?.categoryBreakdown && analytics.categoryBreakdown.length > 0) {
|
||||
lines.push('--- CATEGORY BREAKDOWN ---');
|
||||
lines.push('Category,Applications Count,Percentage');
|
||||
for (const cat of analytics.categoryBreakdown) {
|
||||
lines.push(`"${cat.category}",${cat.count},${cat.percentage}%`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Section 5: Urgent Applications
|
||||
if (urgentApps.length > 0) {
|
||||
lines.push('--- SLA PRIORITY WORKLIST ---');
|
||||
lines.push(
|
||||
'Application Number,Company / Applicant,License Type,Category,Status,Hours Remaining / Overdue',
|
||||
);
|
||||
for (const app of urgentApps) {
|
||||
lines.push(
|
||||
`"${app.applicationNumber}","${app.companyName ?? 'Applicant'}","${app.licenseTypeName}","${app.category}","${app.isOverdue ? 'OVERDUE' : 'AT RISK'}","${app.hoursLeft}h"`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const csvContent = lines.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`EMA-Operations-Dashboard-${period}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notify.success('Executive dashboard CSV report generated and downloaded.');
|
||||
};
|
||||
|
||||
const isInitialLoading = queue.isLoading && analyticsQuery.isLoading;
|
||||
if (isInitialLoading) {
|
||||
return <PageLoader label="Loading Backoffice Operations Dashboard…" height={450} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* Header & Quick Action Jump Bar */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<PageHeader
|
||||
title="Operations Dashboard"
|
||||
subtitle="Central administrative hub for review queues, turnaround SLAs, and departmental operations."
|
||||
noMargin
|
||||
/>
|
||||
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{/* Time Window Selector */}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
data={[
|
||||
{ label: '7 Days', value: '7d' },
|
||||
{ label: '30 Days', value: '30d' },
|
||||
{ label: '6 Months', value: '6m' },
|
||||
{ label: '1 Year', value: '1y' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip label="Export full dashboard summary as CSV">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconDownload size={15} />}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Refresh all live data">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={handleRefreshAll}
|
||||
loading={analyticsQuery.isFetching || queue.isFetching}
|
||||
>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
leftSection={<IconInbox size={16} />}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Licence Queue
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
leftSection={<IconUserCheck size={16} />}
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
Seafarers
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="cyan"
|
||||
size="sm"
|
||||
leftSection={<IconShip size={16} />}
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
Vessels
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* SLA Alert Banner if work has breached deadline */}
|
||||
{overdueSlaCount > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<IconAlertTriangle size={20} />}
|
||||
title="Attention: SLA Turnaround Threshold Exceeded"
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<Text size="sm">
|
||||
<Text span fw={700}>
|
||||
{overdueSlaCount} {overdueSlaCount === 1 ? 'application' : 'applications'}
|
||||
</Text>{' '}
|
||||
have exceeded the official service level agreement timeframe and require immediate officer action.
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Review Overdue Work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Primary Executive KPI Tiles (Row of 6) */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 6 }} spacing="md">
|
||||
<StatTile
|
||||
label="Awaiting claim"
|
||||
value={unclaimed.length}
|
||||
hint="Licence applications nobody has picked up"
|
||||
label="Awaiting Claim"
|
||||
value={unclaimedCount}
|
||||
hint="Unclaimed pool in queue"
|
||||
icon={IconInbox}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Assigned to me"
|
||||
value={inProgress.length}
|
||||
hint="Your open licence reviews"
|
||||
label="Assigned to Me"
|
||||
value={assignedToMeCount}
|
||||
hint="Your claimed active reviews"
|
||||
icon={IconFileText}
|
||||
tone="neutral"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Needs applicant action"
|
||||
value={needsApplicant}
|
||||
hint="Returned for corrections"
|
||||
label="In Review (All)"
|
||||
value={inProgressTotalCount}
|
||||
hint="Under review across all staff"
|
||||
icon={IconUsers}
|
||||
tone="info"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Action Required"
|
||||
value={needsApplicantCount}
|
||||
hint="Returned for applicant edits"
|
||||
icon={IconAlertTriangle}
|
||||
tone="pending"
|
||||
/>
|
||||
<StatTile
|
||||
label="Awaiting payment"
|
||||
value={awaitingPayment}
|
||||
hint="Approved, fee not yet settled"
|
||||
label="Awaiting Payment"
|
||||
value={awaitingPaymentCount}
|
||||
hint="Approved, fee unsettled"
|
||||
icon={IconCreditCard}
|
||||
tone="warning"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Same 4-column track as the row above, so a two-tile row lines up with
|
||||
it instead of stretching each tile to half the page. */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<StatTile
|
||||
label="Seafarer registrations"
|
||||
value={show(countOf(registrations))}
|
||||
hint="Submitted, awaiting review"
|
||||
icon={IconUserCheck}
|
||||
tone="info"
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
/>
|
||||
<StatTile
|
||||
label="Seaman books"
|
||||
value={show(countOf(seamanBooks))}
|
||||
hint="Released, awaiting payment"
|
||||
icon={IconCreditCard}
|
||||
tone="pending"
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
label="SLA Overdue"
|
||||
value={overdueSlaCount}
|
||||
hint={overdueSlaCount > 0 ? 'Urgent attention required' : 'All within SLA window'}
|
||||
icon={IconClockExclamation}
|
||||
tone={overdueSlaCount > 0 ? 'danger' : 'neutral'}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Multi-Department Operations & Cross-Functional Strip */}
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={24} radius="sm" variant="light" color="blue">
|
||||
<IconFolders size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Cross-Department Workloads & Revenue
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Live pipeline counts across Maritime Authority desks
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 5 }} spacing="md" mt="sm">
|
||||
{/* Dept 1: Seafarer Registrations */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
transition: 'transform 120ms ease',
|
||||
}}
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Seafarer Regs
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="teal">
|
||||
<IconUserCheck size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="teal">
|
||||
{show(seafarerRegCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted for review
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 2: Seaman Books */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Seaman Books
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="indigo">
|
||||
<IconCertificate size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="indigo">
|
||||
{show(seamanBookCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Pending fee & issuance
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 3: Basic Training Certificates */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/btc-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
BTC Training
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="blue">
|
||||
<IconCertificate size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="blue">
|
||||
{show(btcCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Certificates in progress
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 4: Vessel Registry */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Vessel Registry
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="cyan">
|
||||
<IconShip size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={22} fw={800} c="cyan">
|
||||
{show(vesselCount)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Vessels registered
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Dept 5: Settled Fees / Revenue */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Settled Fees
|
||||
</Text>
|
||||
<ThemeIcon size={20} radius="xl" variant="light" color="green">
|
||||
<IconCash size={12} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} c="green" lineClamp={1}>
|
||||
{formattedRevenue}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Collections to date
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
{/* Interactive Visualizations & Charts with Selected Period Label */}
|
||||
<DashboardCharts
|
||||
analytics={analytics}
|
||||
periodLabel={periodLabelMap[period] ?? period}
|
||||
fallbackStatusDistribution={[
|
||||
{ name: 'Awaiting Claim', value: unclaimed.length, color: '#339af0' },
|
||||
{ name: 'Assigned to Me', value: inProgress.length, color: '#4c6ef5' },
|
||||
{ name: 'Action Required', value: fallbackNeedsApplicant, color: '#fab005' },
|
||||
{ name: 'Awaiting Payment', value: fallbackAwaitingPayment, color: '#fd7e14' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Operational Worklists & Actionable Queues */}
|
||||
<Grid gutter="lg">
|
||||
{/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting claim — oldest first"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
toolbar={
|
||||
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
|
||||
Open queue
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
<Card withBorder radius="lg" p="md" h="100%">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="space-between" mb="md" wrap="nowrap">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="unclaimed"
|
||||
leftSection={<IconInbox size={15} />}
|
||||
>
|
||||
Unclaimed Work Pool ({byAge.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="sla"
|
||||
leftSection={<IconClock size={15} />}
|
||||
color={urgentApps.length > 0 ? 'red' : 'gray'}
|
||||
>
|
||||
SLA Priority ({urgentApps.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Anchor
|
||||
size="sm"
|
||||
c="blue"
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
View Full Queue <IconChevronRight size={12} />
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Tabs.Panel value="unclaimed">
|
||||
<AdvancedTable<LicenseApplication>
|
||||
title="Awaiting Claim — Oldest First"
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={pagedUnclaimed.rows}
|
||||
itemCount={pagedUnclaimed.itemCount}
|
||||
pageIndex={pagedUnclaimed.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
|
||||
refresh={queue.refetch}
|
||||
isLoading={queue.isFetching}
|
||||
emptyText="No applications currently waiting to be claimed."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sla">
|
||||
{urgentApps.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
All applications are safely within their service level agreement deadlines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{urgentApps.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: item.isOverdue
|
||||
? 'var(--mantine-color-red-outline)'
|
||||
: 'var(--mantine-color-yellow-outline)',
|
||||
}}
|
||||
onClick={() => navigate(`/licence-review/${item.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={28}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={item.isOverdue ? 'red' : 'yellow'}
|
||||
>
|
||||
<IconClockExclamation size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="sm" c="blue">
|
||||
{item.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.companyName ?? 'Applicant'} • {item.licenseTypeName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.isOverdue ? 'red' : 'yellow'}
|
||||
size="sm"
|
||||
>
|
||||
{item.isOverdue
|
||||
? `Overdue by ${Math.abs(item.hoursLeft)}h`
|
||||
: `${item.hoursLeft}h remaining`}
|
||||
</Badge>
|
||||
<IconChevronRight size={14} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Side Panel: Longest Waiting & Fast Directory */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper withBorder radius="lg" p="lg" h="100%">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Longest waiting
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications, by how long they have sat.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting.
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Longest Waiting Applications Card */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Longest Waiting Applications
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<IconClock size={16} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Unclaimed applications sitting in queue by age.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{byAge.slice(0, 5).map((app) => (
|
||||
<Group
|
||||
key={app.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
>
|
||||
<Anchor
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
fw={500}
|
||||
>
|
||||
{app.applicationNumber}
|
||||
</Anchor>
|
||||
<WaitingFor
|
||||
since={app.submittedAt ?? app.createdAt}
|
||||
slaDays={
|
||||
app.licenseType?.slaHours
|
||||
? app.licenseType.slaHours / 24
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{byAge.length === 0 && (
|
||||
<Text size="sm" c="dimmed" py="xs">
|
||||
Nothing waiting to be claimed.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Quick Operations Navigation Directory */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ flexGrow: 1 }}>
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
Quick Authority Desks
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Direct access to core backoffice review workspaces.
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconInbox size={16} color="var(--mantine-color-blue-filled)" />
|
||||
<Text size="sm">Licence Review Queue</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/licence-register')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconCertificate size={16} color="var(--mantine-color-teal-filled)" />
|
||||
<Text size="sm">Official Licence Register</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/seafarer-registrations')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconUserCheck size={16} color="var(--mantine-color-indigo-filled)" />
|
||||
<Text size="sm">Seafarer Registrations</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/seaman-book-queue')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconCreditCard size={16} color="var(--mantine-color-orange-filled)" />
|
||||
<Text size="sm">Seaman Books & BTC</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/vessel-registration-queue')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconShip size={16} color="var(--mantine-color-cyan-filled)" />
|
||||
<Text size="sm">Vessel Register Queue</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
justify="space-between"
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
fullWidth
|
||||
onClick={() => navigate('/exams')}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconFolders size={16} color="var(--mantine-color-grape-filled)" />
|
||||
<Text size="sm">Examinations & CoC</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,27 +41,25 @@ 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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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';
|
||||
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
@@ -79,6 +83,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 +93,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 +108,117 @@ 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 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 +226,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 +265,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 +476,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 +563,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 +589,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 +634,6 @@ function ActionRequired({
|
||||
);
|
||||
}
|
||||
|
||||
/** What the applicant has to do next, and where that happens. */
|
||||
function detailFor(
|
||||
app: LicenseApplication,
|
||||
t: TFunction,
|
||||
@@ -385,48 +675,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 +704,6 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ApplicationTable({
|
||||
applications,
|
||||
navigate,
|
||||
@@ -492,30 +738,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">
|
||||
|
||||
@@ -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: 'እርምጃ የሚያስፈልገው',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user