From d66484921be63ccea22ba058baa6fa4585697160 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:40:55 +0300 Subject: [PATCH] feat: implement comprehensive dashboard UI components, analytics integration, and internationalization support for portal and backoffice apps. --- .../pages/DashboardPage/DashboardCharts.tsx | 488 ++++++++++ .../dashboard/pages/DashboardPage/columns.tsx | 54 +- .../dashboard/pages/DashboardPage/index.tsx | 889 +++++++++++++++--- .../pages/DashboardPage/DashboardCharts.tsx | 361 +++++++ .../pages/DashboardPage/DashboardStats.tsx | 100 ++ .../DashboardPage/ExportStatementModal.tsx | 258 +++++ .../DashboardPage/OnboardingChecklistCard.tsx | 239 +++++ .../pages/DashboardPage/QuickActionsCard.tsx | 228 +++++ .../dashboard/pages/DashboardPage/columns.tsx | 66 +- .../dashboard/pages/DashboardPage/index.tsx | 556 +++++++---- apps/portal/src/app/i18n/locales/am.ts | 80 ++ apps/portal/src/app/i18n/locales/en.ts | 80 ++ .../lib/features/licensing/licensing-api.ts | 20 + .../lib/features/licensing/licensing.types.ts | 118 +++ 14 files changed, 3225 insertions(+), 312 deletions(-) create mode 100644 apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardStats.tsx create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/ExportStatementModal.tsx create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/OnboardingChecklistCard.tsx create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/QuickActionsCard.tsx diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx new file mode 100644 index 000000000..17427b9f8 --- /dev/null +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx @@ -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 ( + + + + {Icon && ( + + + + )} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + {badge} + + + {empty ? ( + + + {emptyText ?? 'No data available for this chart yet.'} + + + ) : ( + + + {children as never} + + + )} + + ); +} + +const CATEGORY_NAMES: Record = { + 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 ( + + + {/* Chart 1: Application Intake & Decisions Trend */} + + {periodLabel} + + } + empty={trendData.length === 0} + > + + + + + + + + + + + + + + + [ + val, + name === 'submitted' + ? 'Submitted' + : name === 'approved' + ? 'Approved / Issued' + : 'Rejected', + ]} + /> + + + + + + + {/* Chart 2: Pipeline Distribution Donut */} + + Live Workload + + } + empty={statusData.length === 0} + > + + [`${val} applications`, name]} + /> + + + {statusData.map((entry, index) => ( + + ))} + + + + + + + {/* Chart 3: Category Workload Breakdown */} + + + + + + [`${val} applications`, 'Volume']} + /> + + {categoryData.map((entry, index) => ( + + ))} + + + + + {/* Chart 4: Service Level Agreement (SLA) Health */} + + + + + + + + + SLA Health & Queue Turnaround + + + Turnaround compliance against published authority SLAs + + + + + {slaRate}% On-Time + + + + + + + + {slaRate}% + + + Compliance + + + + } + /> + + + + + + + + Within SLA + + + + {withinSlaCount} + + + Applications on schedule + + + + 0 + ? 'var(--mantine-color-red-light)' + : 'var(--mantine-color-gray-light)', + }} + > + + 0 + ? 'var(--mantine-color-red-filled)' + : 'var(--mantine-color-dimmed)' + } + /> + 0 ? 'red' : 'dimmed'}> + SLA Breached + + + 0 ? 'red' : 'dimmed'} mt={4}> + {overdueCount} + + + Require priority action + + + + + + + {/* Chart 5: Officer Workload Distribution */} + {officerData.length > 0 && ( + + Staff Capacity + + } + > + + + + + [ + `${val} applications`, + name === 'onScheduleCount' ? 'On Schedule' : 'SLA Overdue', + ]} + /> + + + + + + )} + + ); +} diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx index 593107bd1..39fdcbf75 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx @@ -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[] = [ { - header: 'Number', + header: 'Application #', cell: ({ row }) => ( - + {row.original.applicationNumber} ), }, { - header: 'Company', - cell: ({ row }) => {row.original.companyName ?? '—'}, + header: 'Company / Applicant', + cell: ({ row }) => ( + + {row.original.companyName || row.original.tradeName || '—'} + + ), + }, + { + header: 'License Type', + cell: ({ row }) => ( + + {row.original.licenseType?.name?.en ?? 'Maritime Service'} + + ), + }, + { + header: 'Waiting Since', + cell: ({ row }) => ( + + ), }, { header: 'Status', - cell: ({ row }) => ( - - {STATUS_LABELS[row.original.status as LicenseStatus]} - - ), + cell: ({ row }) => { + const status = row.original.status as LicenseStatus; + return ( + + {STATUS_LABELS[status] ?? status} + + ); + }, }, ]; diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx index 0d5064448..ed2e6d8bb 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -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('unclaimed'); + const [period, setPeriod] = useState('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 ; - } + 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 ( - - + // 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; - + // 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 = { + '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 ; + } + + return ( + + {/* Header & Quick Action Jump Bar */} + + + + + + {/* Time Window Selector */} + + + + } + onClick={handleExportCsv} + > + Export CSV + + + + + + + + + + } + onClick={() => navigate('/licence-review')} + > + Licence Queue + + } + onClick={() => navigate('/seafarer-registrations')} + > + Seafarers + + } + onClick={() => navigate('/vessel-registration-queue')} + > + Vessels + + + + + + {/* SLA Alert Banner if work has breached deadline */} + {overdueSlaCount > 0 && ( + } + title="Attention: SLA Turnaround Threshold Exceeded" + > + + + + {overdueSlaCount} {overdueSlaCount === 1 ? 'application' : 'applications'} + {' '} + have exceeded the official service level agreement timeframe and require immediate officer action. + + navigate('/licence-review')} + > + Review Overdue Work + + + + )} + + {/* Primary Executive KPI Tiles (Row of 6) */} + navigate('/licence-review')} /> navigate('/licence-review')} /> navigate('/licence-review')} + /> + - - - {/* 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. */} - navigate('/seafarer-registrations')} - /> - 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')} /> + {/* Multi-Department Operations & Cross-Functional Strip */} + + + + + + + + Cross-Department Workloads & Revenue + + + + Live pipeline counts across Maritime Authority desks + + + + + {/* Dept 1: Seafarer Registrations */} + navigate('/seafarer-registrations')} + > + + + Seafarer Regs + + + + + + + {show(seafarerRegCount)} + + + Submitted for review + + + + {/* Dept 2: Seaman Books */} + navigate('/seaman-book-queue')} + > + + + Seaman Books + + + + + + + {show(seamanBookCount)} + + + Pending fee & issuance + + + + {/* Dept 3: Basic Training Certificates */} + navigate('/btc-queue')} + > + + + BTC Training + + + + + + + {show(btcCount)} + + + Certificates in progress + + + + {/* Dept 4: Vessel Registry */} + navigate('/vessel-registration-queue')} + > + + + Vessel Registry + + + + + + + {show(vesselCount)} + + + Vessels registered + + + + {/* Dept 5: Settled Fees / Revenue */} + + + + Settled Fees + + + + + + + {formattedRevenue} + + + Collections to date + + + + + + {/* Interactive Visualizations & Charts with Selected Period Label */} + + + {/* Operational Worklists & Actionable Queues */} + {/* Main Worklist: Oldest Unclaimed Pool or SLA Critical */} - - 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={ - navigate('/licence-review')}> - Open queue - - } - /> + + + + + } + > + Unclaimed Work Pool ({byAge.length}) + + } + color={urgentApps.length > 0 ? 'red' : 'gray'} + > + SLA Priority ({urgentApps.length}) + + + + navigate('/licence-review')} + > + View Full Queue + + + + + + 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." + /> + + + + {urgentApps.length === 0 ? ( + + All applications are safely within their service level agreement deadlines. + + ) : ( + + {urgentApps.map((item) => ( + navigate(`/licence-review/${item.id}`)} + > + + + + + + + + {item.applicationNumber} + + + {item.companyName ?? 'Applicant'} • {item.licenseTypeName} + + + + + + + {item.isOverdue + ? `Overdue by ${Math.abs(item.hoursLeft)}h` + : `${item.hoursLeft}h remaining`} + + + + + + ))} + + )} + + + + {/* Side Panel: Longest Waiting & Fast Directory */} - - - Longest waiting - - - Unclaimed applications, by how long they have sat. - - - {byAge.slice(0, 5).map((app) => ( - - navigate(`/licence-review/${app.id}`)} - > - {app.applicationNumber} - - - - ))} - {byAge.length === 0 && ( - - Nothing waiting. + + {/* Longest Waiting Applications Card */} + + + + Longest Waiting Applications - )} - - + + + + Unclaimed applications sitting in queue by age. + + + {byAge.slice(0, 5).map((app) => ( + + navigate(`/licence-review/${app.id}`)} + fw={500} + > + {app.applicationNumber} + + + + ))} + {byAge.length === 0 && ( + + Nothing waiting to be claimed. + + )} + + + + {/* Quick Operations Navigation Directory */} + + + Quick Authority Desks + + + Direct access to core backoffice review workspaces. + + + + } + fullWidth + onClick={() => navigate('/licence-review')} + > + + + Licence Review Queue + + + + } + fullWidth + onClick={() => navigate('/licence-register')} + > + + + Official Licence Register + + + + } + fullWidth + onClick={() => navigate('/seafarer-registrations')} + > + + + Seafarer Registrations + + + + } + fullWidth + onClick={() => navigate('/seaman-book-queue')} + > + + + Seaman Books & BTC + + + + } + fullWidth + onClick={() => navigate('/vessel-registration-queue')} + > + + + Vessel Register Queue + + + + } + fullWidth + onClick={() => navigate('/exams')} + > + + + Examinations & CoC + + + + + diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx new file mode 100644 index 000000000..b2a1915c3 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardCharts.tsx @@ -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 ( + + + + + {viewMode === 'timeline' ? : } + + + + {viewMode === 'timeline' + ? t('dashboard.charts.activityTitle') + : t('dashboard.charts.categoryTitle')} + + + {viewMode === 'timeline' + ? t('dashboard.charts.activitySubtitle') + : t('dashboard.charts.categoryCount')} + + + + + {totalApplications > 0 && ( + setViewMode(val as 'timeline' | 'category')} + data={[ + { label: t('dashboard.charts.viewTimeline'), value: 'timeline' }, + { label: t('dashboard.charts.viewCategory'), value: 'category' }, + ]} + /> + )} + + + + {totalApplications === 0 ? ( + + + + + + + {t('dashboard.charts.noData')} + + + + ) : viewMode === 'timeline' ? ( + + + + + + + + + + + + + + + + [ + value as string | number, + name === 'submitted' + ? t('dashboard.charts.submitted') + : t('dashboard.charts.approved'), + ]} + /> + + + + + ) : ( + + + + + + [value as string | number, t('dashboard.charts.categoryCount')]} + /> + + {categoryBreakdown.map((_entry, index) => ( + + ))} + + + + )} + + + ); +} + +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 ( + + + + {t('dashboard.charts.statusTitle')} + + + + + + + + + + + 1 ? 3 : 0} + dataKey="value" + stroke="none" + > + {pieData.map((entry, index) => ( + + ))} + + + + + + + {totalApplications} + + + {t('dashboard.charts.total')} + + + + + + {statusDistribution.map((item) => { + const pct = + totalApplications > 0 + ? Math.round((item.value / totalApplications) * 100) + : 0; + return ( + + + + + {item.name} + + + + + {item.value} + + + {pct}% + + + + ); + })} + + + + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardStats.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardStats.tsx new file mode 100644 index 000000000..c717f13b9 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/DashboardStats.tsx @@ -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 ( + + {stats.map((stat) => { + const IconComponent = stat.icon; + return ( + + + + + {stat.title} + + + {stat.value} + + + {stat.trend} + + + + + + + + ); + })} + + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/ExportStatementModal.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/ExportStatementModal.tsx new file mode 100644 index 000000000..5b25c72b2 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/ExportStatementModal.tsx @@ -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 ( + + + + + + {t('dashboard.export.title')} + + + } + > + + {/* Statement Header */} + + + + + {t('app.authority', 'Ethiopian Maritime Authority')} + + + {displayName || t('dashboard.export.accountHolder')} + + + {t('dashboard.export.subtitle')} + + + + + + {t('dashboard.export.issueDate')} + + + {todayStr} + + + Electronic Copy + + + + + + {/* Snapshot Summary Metrics */} + + + + {t('dashboard.export.activeLicencesCount')} + + + {activeLicenses.length} + + + + + + {t('dashboard.export.pendingApplicationsCount')} + + + {inProgressApps.length} + + + + + + {t('dashboard.export.verifiedDocumentsCount')} + + + {documentsCount} + + + + + {/* Active Licences Table */} + + + {t('dashboard.sections.myLicences.title')} ({activeLicenses.length}) + + {activeLicenses.length === 0 ? ( + + {t('applications.licences.empty')} + + ) : ( + + + + Certificate # + Licence Type + Expiry Date + Status + + + + {activeLicenses.map((lic) => ( + + {lic.certificateNumber} + {localized(lic.licenseType?.name) || '—'} + + {lic.expiryDate + ? new Date(lic.expiryDate).toLocaleDateString('en-GB') + : '—'} + + + + Active + + + + ))} + + + )} + + + {/* Recent Applications Table */} + + + {t('dashboard.sections.myApplications.title')} ({applications.slice(0, 5).length}) + + {applications.length === 0 ? ( + + {t('dashboard.sections.myApplications.empty')} + + ) : ( + + + + Application # + Licence Type + Submitted + Status + + + + {applications.slice(0, 5).map((app) => ( + + {app.applicationNumber} + {localized(app.licenseType?.name) || '—'} + + {app.createdAt + ? new Date(app.createdAt).toLocaleDateString('en-GB') + : '—'} + + + + {STATUS_LABELS[app.status] ?? app.status} + + + + ))} + + + )} + + + + + + {t('dashboard.export.officialFooter')} + + + {/* Modal Actions */} + + + {t('dashboard.export.close')} + + } + onClick={handlePrint} + > + {t('dashboard.export.printButton')} + + + + + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/OnboardingChecklistCard.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/OnboardingChecklistCard.tsx new file mode 100644 index 000000000..05ab46056 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/OnboardingChecklistCard.tsx @@ -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 ? ( + navigate('/profile')} + > + {t('common.continue')} + + ) : null, + }, + { + id: 2, + title: t('dashboard.onboarding.step2'), + desc: t('dashboard.onboarding.step2Desc'), + icon: IconFolder, + completed: step2Complete, + action: !step2Complete ? ( + navigate('/documents')} + rightSection={} + > + {t('dashboard.onboarding.viewVault')} + + ) : null, + }, + { + id: 3, + title: t('dashboard.onboarding.step3'), + desc: t('dashboard.onboarding.step3Desc'), + icon: IconRocket, + completed: step3Complete, + action: ( + } + > + {t('dashboard.onboarding.browseCatalogue')} + + ), + }, + ]; + + return ( + + + + + + + + + {t('dashboard.onboarding.title')} + + + {t('dashboard.onboarding.subtitle')} + + + + + + + {t('dashboard.onboarding.progress', { + completed: completedCount, + total: 3, + })} + + + + + + + + {steps.map((step) => { + const StepIcon = step.icon; + return ( + + + + + {step.completed ? ( + + ) : ( + + )} + + + + + {step.title} + + {step.completed && ( + + {t('dashboard.onboarding.completed')} + + )} + + + {step.desc} + + + + + {step.action && {step.action}} + + + ); + })} + + + ); +} + +function Title({ order, fw, children }: { order: 1 | 2 | 3 | 4 | 5 | 6; fw: number; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/QuickActionsCard.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/QuickActionsCard.tsx new file mode 100644 index 000000000..90bc9c0ea --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/QuickActionsCard.tsx @@ -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 ( + <> + + + + {t('dashboard.quickActions')} + + + + + {actions.map((item) => { + const IconComp = item.icon; + return ( + + + + + + + + + {item.title} + + + {item.description} + + + + + + + ); + })} + + + + setSupportOpened(false)} + title={ + + + + + {t('dashboard.supportModal.title')} + + } + radius="md" + centered + > + + + {t('dashboard.supportModal.description')} + + + + + + + + + + + {t('dashboard.supportModal.emailLabel')} + + + {t('dashboard.supportModal.email')} + + + + + + + + + + + + + {t('dashboard.supportModal.phoneLabel')} + + + {t('dashboard.supportModal.phone')} + + + {t('dashboard.supportModal.hours')} + + + + + + + + + + + + + {t('dashboard.supportModal.officeLabel')} + + + {t('dashboard.supportModal.office')} + + + + + + + setSupportOpened(false)} + > + {t('dashboard.supportModal.close')} + + + + > + ); +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx index 67727442d..a41b85fa6 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx @@ -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[] { @@ -16,40 +30,60 @@ export function dashboardApplicationColumns( { header: t('dashboard.table.application'), cell: ({ row }) => ( - <> - + + {row.original.applicationNumber} - + {row.original.companyName ?? '—'} - > + ), }, { - header: t('applications.table.licence'), + header: t('dashboard.table.type'), cell: ({ row }) => ( - {localized(row.original.licenseType?.name) || '—'} + + {localized(row.original.licenseType?.name) || '—'} + + ), + }, + { + header: t('dashboard.table.submitted'), + cell: ({ row }) => ( + + {formatSubmittedDate(row.original.createdAt)} + ), }, { header: t('common.status'), cell: ({ row }) => ( - - {STATUS_LABELS[row.original.status]} + + {STATUS_LABELS[row.original.status] ?? row.original.status} ), }, { header: t('applications.table.progress'), - size: 180, + size: 140, cell: ({ row }) => ( - + + + + {STATUS_PROGRESS[row.original.status] ?? 30}% + + ), }, ]; diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx index ff01b68dd..4fa8860f6 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -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(() => { + 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(() => { + 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 = {}; + 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 ; } @@ -120,19 +226,24 @@ export function DashboardPage() { return ( + {/* Coastal Modern Hero Section */} setExportOpened(true)} /> - {/* A prompt, not a gate — dismissible and it never blocks the page. */} + {/* Profile Completion Nudge */} + {/* Priority Action Required */} {needsMe.length > 0 && ( )} + {/* Expiring Soon Notice */} {expiringSoon.length > 0 && ( )} - - {hasNoRecords ? ( - + {/* If user is brand new with 0 applications & 0 licenses, show OnboardingChecklistCard */} + {items.length === 0 && activeLicenses.length === 0 ? ( + ) : ( - <> - - {heldLicenses.length === 0 ? ( - - ) : ( - - {heldLicenses.map((license) => ( - downloadCertificate(license)} - onRenew={() => renewLicense(license)} - /> - ))} - - )} - - - 0 ? ( - navigate('/licensing/applications')} - > - {t('common.viewAll')} - - ) : undefined - } - > - {items.length === 0 ? ( - - ) : ( - - )} - - > + + + + + + + + )} - - - + {/* Two-Column Section: Left (Applications & Licenses), Right (Actions & Support) */} + + + + {/* Recent Applications Card */} + + + + {t('dashboard.sections.myApplications.title')} + + {t('applications.subtitle')} + + + + {items.length > 0 && ( + 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 && ( + navigate('/licensing/applications')} + > + + {t('common.viewAll')} + + + + )} + + + + {filteredApplications.length === 0 ? ( + + ) : ( + + )} + + + {/* My Licences Section */} + 0 ? ( + navigate('/licensing/applications')} + > + + {t('common.viewAll')} + + + + ) : undefined + } + > + {heldLicenses.length === 0 ? ( + + ) : ( + + {heldLicenses.map((license) => ( + downloadCertificate(license)} + onRenew={() => renewLicense(license)} + /> + ))} + + )} + + + + + {/* Right Column: Quick Actions & Help */} + + + + + + + + + + + {t('dashboard.supportModal.title')} + + + + {t('dashboard.supportModal.description')} + + navigate('/licensing/applications')} + > + {t('common.learnMore')} + + + + + + + {/* License Catalogue Section */} + + + + + + + {/* Statement Export Modal */} + setExportOpened(false)} + displayName={displayName} + applications={items} + licenses={heldLicenses} + documentsCount={documentsCount} + /> ); } -// --------------------------------------------------------------- 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 ( - - - - {t('app.authority')} + + + + {t('app.authority', 'Ethiopian Maritime Authority')} - + {displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')} - + {summary} + + } + onClick={onApplyClick} + style={{ fontWeight: 600 }} + > + {t('dashboard.quickActionsList.applyLicense')} + + {onExportClick && ( + } + 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')} + + )} + - - - + + ); @@ -285,12 +563,19 @@ function ActionRequired({ navigate: (path: string) => void; }) { const { t } = useTranslation(); + const computedColorScheme = useComputedColorScheme('light'); + const isDark = computedColorScheme === 'dark'; + return ( @@ -304,7 +589,13 @@ function ActionRequired({ {applications.map((app) => { const detail = detailFor(app, t); return ( - + - {stats.map((stat) => ( - - - - - {stat.label} - - - {stat.value} - - - - - - - - ))} - - ); -} - 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 ( - - - - - - - {t('dashboard.getStarted.title')} - - {t('dashboard.getStarted.body')} - - - - - ); -} - function EmptyCard({ message }: { message: string }) { return ( diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 4ab903447..4a58cd487 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: 'እርምጃ የሚያስፈልገው', }, }, diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index dd6aaea6e..548251abc 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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', }, }, diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index eec04b739..3691845ae 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -1,5 +1,7 @@ import { baseApi } from '../../base-api'; import type { + AdminDashboardAnalytics, + ApplicantDashboardSummary, ExamStateView, AppNotification, ApplicationDetail, @@ -492,6 +494,11 @@ export const licensingApi = baseApi providesTags: () => [listTag('LicenseApplication')], }), + getMyDashboardSummary: builder.query({ + query: () => ({ url: '/license-applications/mine/summary' }), + providesTags: () => [listTag('LicenseApplication'), listTag('License')], + }), + getApplication: builder.query({ query: (id) => ({ url: `/license-applications/${id}` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], @@ -746,6 +753,17 @@ export const licensingApi = baseApi providesTags: () => [listTag('ApplicationQueue')], }), + getAdminDashboardAnalytics: builder.query< + AdminDashboardAnalytics, + { period?: string } | void + >({ + query: (args) => ({ + url: '/license-application-review/dashboard-analytics', + params: args?.period ? { period: args.period } : undefined, + }), + providesTags: () => [listTag('ApplicationQueue'), listTag('License')], + }), + getApplicationForReview: builder.query({ query: (id) => ({ url: `/license-application-review/${id}` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], @@ -1386,6 +1404,7 @@ export const { useGetExamStateForApplicationQuery, useDiscardApplicationMutation, useGetMyApplicationsQuery, + useGetMyDashboardSummaryQuery, useGetApplicationQuery, useInitiatePaymentMutation, useBypassPaymentMutation, @@ -1409,6 +1428,7 @@ export const { useGetAssignedToMeQuery, useGetAllApplicationsQuery, useGetQueueCountsQuery, + useGetAdminDashboardAnalyticsQuery, useGetLicenseTemplatesQuery, useGetTemplateVariablesQuery, useGetBuiltInTemplateQuery, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index e20c4029a..15a608ffd 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -947,3 +947,121 @@ export interface ExamStateView { /** Whether the exam engine produced the mark, or a person did. */ autoGraded: boolean | null; } + +export interface ApplicantDashboardSummary { + counts: { + totalApplications: number; + activeLicenses: number; + expiringSoonLicenses: number; + pendingApplications: number; + approvedApplications: number; + draftApplications: number; + actionRequiredApplications: number; + }; + statusDistribution: Array<{ + name: string; + value: number; + color: string; + }>; + monthlyTrend: Array<{ + month: string; + year: number; + submitted: number; + approved: number; + }>; + categoryBreakdown: Array<{ + category: string; + count: number; + }>; +} + +export interface AdminDashboardAnalytics { + kpis: { + totalApplications: number; + unclaimedQueue: number; + assignedToMe: number; + inProgressTotal: number; + needsApplicant: number; + awaitingPayment: number; + approved: number; + rejected: number; + overdueSla: number; + withinSla: number; + slaComplianceRate: number; + activeLicenses: number; + revenue: { + totalCollected: number; + pendingAmount: number; + currency: string; + }; + }; + statusDistribution: Array<{ + name: string; + value: number; + color: string; + }>; + monthlyTrend: Array<{ + month: string; + year: number; + submitted: number; + approved: number; + rejected: number; + revenue: number; + }>; + categoryBreakdown: Array<{ + category: string; + count: number; + percentage: number; + }>; + departmentSummary: { + seafarerRegistrations: { + total: number; + submitted: number; + approved: number; + }; + seamanBooks: { + total: number; + pending: number; + issued: number; + }; + btc: { + total: number; + pending: number; + issued: number; + }; + vessels: { + total: number; + active: number; + pending: number; + }; + }; + oldestUnclaimed: Array<{ + id: string; + applicationNumber: string; + companyName: string; + licenseTypeName: string; + category: string; + status: string; + submittedAt: string; + slaHours: number | null; + }>; + urgentApplications: Array<{ + id: string; + applicationNumber: string; + companyName: string | null; + licenseTypeName: string; + category: string; + submittedAt: string | null; + hoursLeft: number; + isOverdue: boolean; + }>; + officerWorkload: Array<{ + officerId: string; + officerName: string; + activeCount: number; + overdueCount: number; + onScheduleCount: number; + }>; + period?: string; +} +