feat: implement comprehensive dashboard UI components, analytics integration, and internationalization support for portal and backoffice apps.

This commit is contained in:
Estifo77
2026-09-03 14:40:55 +03:00
parent 92f2f16b80
commit d66484921b
14 changed files with 3225 additions and 312 deletions

View File

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

View File

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

View File

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