diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
index 1c25a86cf..3738afc99 100644
--- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
+++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import {
- ActionIcon,
Alert,
Anchor,
Badge,
@@ -11,7 +10,6 @@ import {
Card,
Center,
Container,
- Divider,
Group,
Loader,
Paper,
@@ -22,7 +20,6 @@ import {
Text,
ThemeIcon,
Title,
- Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
@@ -30,9 +27,7 @@ import {
IconClipboardList,
IconClockHour4,
IconCreditCard,
- IconDownload,
IconFileText,
- IconRefresh,
IconShieldCheck,
} from '@tabler/icons-react';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
@@ -42,16 +37,14 @@ import {
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
- extractErrorMessage,
localized,
- useCreateApplicationMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
-import { notify } 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';
/**
* The applicant's home screen.
@@ -82,14 +75,6 @@ function formatMoney(amount: string | number | null, currency: string): string {
return `${value.toLocaleString('en-US')} ${currency}`;
}
-function formatDate(value: string): string {
- return new Date(value).toLocaleDateString('en-GB', {
- day: 'numeric',
- month: 'short',
- year: 'numeric',
- });
-}
-
export function DashboardPage() {
const navigate = useNavigate();
const displayName = useSelector(
@@ -101,8 +86,7 @@ export function DashboardPage() {
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
- const [createApplication, { isLoading: isRenewing }] =
- useCreateApplicationMutation();
+ const { renewLicense, isRenewing } = useRenewLicense();
const items = useMemo(() => applications?.items ?? [], [applications]);
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
@@ -127,27 +111,6 @@ export function DashboardPage() {
window.open(result.url, '_blank', 'noopener');
}
- /**
- * Renewal reuses the ordinary application wizard — a renewal is an
- * application of kind RENEWAL, asking for that licence type's renewal
- * document set. `previousLicenseId` is what ties it to the certificate being
- * replaced, and what the API requires.
- */
- async function renewLicense(license: IssuedLicense) {
- const typeKey = license.licenseType?.key;
- if (!typeKey) return;
- try {
- const application = await createApplication({
- licenseType: typeKey,
- kind: 'RENEWAL',
- previousLicenseId: license.id,
- }).unwrap();
- navigate(`/licensing/${typeKey}/applications/${application.id}`);
- } catch (err) {
- notify.error(extractErrorMessage(err), 'Could not start the renewal');
- }
- }
-
if (isLoading) {
return (
@@ -325,12 +288,7 @@ function ActionRequired({
withBorder
radius="md"
padding="md"
- sx={{
- backgroundColor: 'var(--mantine-color-orange-0)',
- '@media (prefers-color-scheme: dark)': {
- backgroundColor: 'var(--mantine-color-orange-9)',
- },
- }}
+ style={{ backgroundColor: 'var(--mantine-color-orange-light)' }}
>
@@ -559,91 +517,6 @@ function ApplicationTable({
);
}
-function LicenseCard({
- license,
- isDownloading,
- isRenewing,
- onDownload,
- onRenew,
-}: {
- license: IssuedLicense;
- isDownloading: boolean;
- isRenewing: boolean;
- onDownload: () => void;
- onRenew: () => void;
-}) {
- // The API computes both in the authority's timezone; the local fallbacks are
- // only for a cached response from before those fields existed.
- const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
- const expired = license.status === 'EXPIRED' || days < 0;
- const renewable = license.renewable ?? false;
-
- return (
-
-
-
-
- {localized(license.licenseType?.name) || 'Licence'}
-
-
- {license.certificateNumber}
-
-
-
- {expired ? 'Expired' : license.status}
-
-
-
-
-
-
-
-
- {expired ? 'Expired on' : 'Valid until'}
-
-
- {formatDate(license.expiryDate)}
-
-
-
-
-
-
-
-
-
- {/* Renewal opens inside the licence type's window and stays open after
- expiry, so a lapsed licence is renewed rather than applied for afresh. */}
- {renewable && (
- }
- onClick={onRenew}
- >
- {expired
- ? 'Renew — this licence has expired'
- : `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
-
- )}
-
- );
-}
-
/**
* 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
diff --git a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx
new file mode 100644
index 000000000..96f82b8b6
--- /dev/null
+++ b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx
@@ -0,0 +1,152 @@
+import { useNavigate } from 'react-router-dom';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Text,
+ Tooltip,
+} from '@mantine/core';
+import { IconDownload, IconRefresh } from '@tabler/icons-react';
+import {
+ extractErrorMessage,
+ localized,
+ useCreateApplicationMutation,
+ type IssuedLicense,
+} from '@ema-platform/api';
+import { notify } from '@ema-platform/ui';
+
+/**
+ * Renewal reuses the ordinary application wizard — a renewal is an
+ * application of kind RENEWAL, asking for that licence type's renewal
+ * document set. `previousLicenseId` is what ties it to the certificate being
+ * replaced, and what the API requires.
+ *
+ * Shared by the dashboard and My Applications so both offer the same renew
+ * action instead of drifting.
+ */
+export function useRenewLicense() {
+ const navigate = useNavigate();
+ const [createApplication, { isLoading: isRenewing }] =
+ useCreateApplicationMutation();
+
+ async function renewLicense(license: IssuedLicense) {
+ const typeKey = license.licenseType?.key;
+ if (!typeKey) return;
+ try {
+ const application = await createApplication({
+ licenseType: typeKey,
+ kind: 'RENEWAL',
+ previousLicenseId: license.id,
+ }).unwrap();
+ navigate(`/licensing/${typeKey}/applications/${application.id}`);
+ } catch (err) {
+ notify.error(extractErrorMessage(err), 'Could not start the renewal');
+ }
+ }
+
+ return { renewLicense, isRenewing };
+}
+
+function daysUntil(date: string): number {
+ const ms = new Date(date).getTime() - Date.now();
+ return Math.ceil(ms / 86_400_000);
+}
+
+function formatDate(value: string): string {
+ return new Date(value).toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}
+
+export function LicenseCard({
+ license,
+ isDownloading,
+ isRenewing,
+ onDownload,
+ onRenew,
+}: {
+ license: IssuedLicense;
+ isDownloading: boolean;
+ isRenewing: boolean;
+ onDownload: () => void;
+ onRenew: () => void;
+}) {
+ // The API computes both in the authority's timezone; the local fallbacks are
+ // only for a cached response from before those fields existed.
+ const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
+ const expired = license.status === 'EXPIRED' || days < 0;
+ const renewable = license.renewable ?? false;
+
+ return (
+
+
+
+
+ {localized(license.licenseType?.name) || 'Licence'}
+
+
+ {license.certificateNumber}
+
+
+
+ {expired ? 'Expired' : license.status}
+
+
+
+
+
+
+
+
+ {expired ? 'Expired on' : 'Valid until'}
+
+
+ {formatDate(license.expiryDate)}
+
+
+
+
+
+
+
+
+
+ {/* Renewal opens inside the licence type's window and stays open after
+ expiry, so a lapsed licence is renewed rather than applied for afresh. */}
+ {renewable && (
+ }
+ onClick={onRenew}
+ >
+ {expired
+ ? 'Renew — this licence has expired'
+ : `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
+
+ )}
+
+ );
+}
+
+export default LicenseCard;
diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.module.css b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.module.css
new file mode 100644
index 000000000..91b438297
--- /dev/null
+++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.module.css
@@ -0,0 +1,55 @@
+/* Segmented "pill" tab bar — matches ProfilePage's tab styling. */
+.list {
+ display: inline-flex;
+ gap: 6px;
+ padding: 5px;
+ background: var(--mantine-color-gray-light);
+ border-radius: var(--mantine-radius-md);
+ border: none;
+ flex-wrap: wrap;
+}
+
+.tab {
+ border: none;
+ border-radius: 10px;
+ padding: 9px 18px;
+ font-weight: 500;
+ color: var(--mantine-color-dimmed);
+ background: transparent;
+ transition:
+ background-color 120ms ease,
+ color 120ms ease,
+ box-shadow 120ms ease;
+}
+
+.tab:hover {
+ background: transparent;
+ color: var(--mantine-color-text);
+}
+
+.tab[data-active],
+.tab[data-active]:hover {
+ background: var(--mantine-color-body);
+ color: var(--mantine-color-emaPrimary-7);
+ font-weight: 600;
+ box-shadow: var(--mantine-shadow-xs);
+}
+
+/* Clickable stat tile that doubles as a filter toggle. */
+.tile {
+ cursor: pointer;
+ border: 1px solid var(--mantine-color-default-border);
+ transition:
+ border-color 120ms ease,
+ background-color 120ms ease;
+}
+
+.tile:hover {
+ border-color: var(--mantine-color-gray-5);
+}
+
+.tileActive,
+.tileActive:hover {
+ border-color: var(--mantine-color-emaPrimary-6);
+ background: var(--mantine-color-emaPrimary-light);
+}
diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx
index f85478a01..f0de46904 100644
--- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx
+++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx
@@ -1,26 +1,49 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import {
+ Alert,
Badge,
+ Box,
Button,
Card,
Container,
Group,
+ Pagination,
Paper,
+ Progress,
Select,
+ SimpleGrid,
+ Skeleton,
Stack,
+ Tabs,
Text,
TextInput,
+ ThemeIcon,
Title,
} from '@mantine/core';
-import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
-import { AdvancedTable, AmharicDatePicker, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
+import {
+ IconAlertTriangle,
+ IconCertificate,
+ IconClipboardList,
+ IconClockHour4,
+ IconCreditCard,
+ IconDownload,
+ IconFileText,
+ IconPlus,
+ IconSearch,
+ IconX,
+} from '@tabler/icons-react';
+import { AmharicDatePicker, EmptyState } from '@ema-platform/ui';
import { LicenseCatalogue } from '../components/LicenseCatalogue';
+import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
+ APPLICANT_ACTION_STATUSES,
STATUS_COLORS,
- STATUS_LABELS,
+ STATUS_PROGRESS,
+ TERMINAL_STATUSES,
applicantOrCompanyName,
extractErrorMessage,
localized,
@@ -31,27 +54,63 @@ import {
useGetPaymentCapabilitiesQuery,
type LicenseApplication,
type LicenseStatus,
- type IssuedLicense,
} from '@ema-platform/api';
+import classes from './MyApplicationsPage.module.css';
-const ALL_APPLICATION_STATUSES = Object.keys(STATUS_LABELS) as LicenseStatus[];
+const PAGE_SIZE = 8;
+
+/** Days before expiry at which a licence is worth flagging. */
+const EXPIRY_WARNING_DAYS = 60;
+
+function daysUntil(date: string): number {
+ const ms = new Date(date).getTime() - Date.now();
+ return Math.ceil(ms / 86_400_000);
+}
+
+type Bucket = 'needsYou' | 'inProgress' | 'completed' | null;
+
+function bucketOf(status: LicenseStatus): Exclude {
+ if (APPLICANT_ACTION_STATUSES.includes(status)) return 'needsYou';
+ if (TERMINAL_STATUSES.includes(status)) return 'completed';
+ return 'inProgress';
+}
+
+type Tab = 'applications' | 'licences' | 'apply';
+const TABS: Tab[] = ['applications', 'licences', 'apply'];
+
+function tabFromHash(hash: string): Tab {
+ const value = hash.replace('#', '');
+ return (TABS as string[]).includes(value) ? (value as Tab) : 'applications';
+}
/**
* The applicant's landing page: which licences they can apply for, and the
* state of anything already filed.
*
- * The licence types come from the backend, so a newly configured type appears
- * here without a code change — and each one carries its own document
- * requirements into the wizard.
+ * Three tabs instead of one long scroll — applications, licences and the
+ * catalogue each own their own space, so a returning applicant lands on
+ * exactly what they came back to check instead of scrolling past it.
*/
export function MyApplicationsPage() {
const navigate = useNavigate();
- const { data, isFetching, refetch } = useGetMyApplicationsQuery();
+ const { t, i18n } = useTranslation();
+ const { data, isFetching } = useGetMyApplicationsQuery();
const { pay, isPaying } = useApplicationPayment();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
- const { data: licences, isFetching: isFetchingLicences, refetch: refetchLicences } = useGetMyLicensesQuery();
+ const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
+ const { renewLicense, isRenewing } = useRenewLicense();
+ const [isDownloadingCert, setIsDownloadingCert] = useState(false);
+
+ const [tab, setTab] = useState(() =>
+ typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
+ );
+
+ function changeTab(next: Tab) {
+ setTab(next);
+ window.history.replaceState(null, '', `#${next}`);
+ }
async function handleBypass(applicationId: string) {
try {
@@ -60,7 +119,7 @@ export function MyApplicationsPage() {
color: 'teal',
title: 'Payment bypassed',
message: result.certificateIssued
- ? 'The licence has been issued — see My licences above.'
+ ? 'The licence has been issued — see the Licences tab.'
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
});
} catch (err) {
@@ -80,15 +139,13 @@ export function MyApplicationsPage() {
* find it in a separate table.
*/
async function openCertificateForApplication(applicationId: string) {
- const licence = (licences?.items ?? []).find(
- (l) => l.applicationId === applicationId,
- );
+ const licence = (licences?.items ?? []).find((l) => l.applicationId === applicationId);
if (!licence) {
notifications.show({
color: 'yellow',
title: 'Certificate not ready',
message:
- 'The licence for this application has not been issued yet. It will appear under My licences.',
+ 'The licence for this application has not been issued yet. It will appear under Licences.',
});
return;
}
@@ -96,6 +153,7 @@ export function MyApplicationsPage() {
}
async function downloadCertificate(licenseId: string) {
+ setIsDownloadingCert(true);
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
window.open(url, '_blank', 'noopener');
@@ -105,21 +163,37 @@ export function MyApplicationsPage() {
title: 'Could not open the certificate',
message: extractErrorMessage(err),
});
+ } finally {
+ setIsDownloadingCert(false);
}
}
const allItems = data?.items ?? [];
const licenceItems = licences?.items ?? [];
+ const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
+ const expiringSoon = activeLicences.filter((l) => {
+ const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
+ return days >= 0 && days <= EXPIRY_WARNING_DAYS;
+ });
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
- const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo);
+ const [bucketFilter, setBucketFilter] = useState(null);
+ const [pageIndex, setPageIndex] = useState(0);
+ const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
+
+ const counts = useMemo(() => {
+ const result = { needsYou: 0, inProgress: 0, completed: 0 };
+ for (const app of allItems) result[bucketOf(app.status)]++;
+ return result;
+ }, [allItems]);
const items = useMemo(() => {
const q = search.trim().toLowerCase();
- return allItems.filter((app) => {
+ const filtered = allItems.filter((app) => {
+ if (bucketFilter && bucketOf(app.status) !== bucketFilter) return false;
if (q) {
const haystack = `${app.applicationNumber} ${applicantOrCompanyName(app) ?? ''}`.toLowerCase();
if (!haystack.includes(q)) return false;
@@ -132,304 +206,423 @@ export function MyApplicationsPage() {
if (dateTo && (!at || at > `${dateTo}T23:59:59.999Z`)) return false;
return true;
});
- }, [allItems, search, statusFilter, dateFrom, dateTo]);
+ // Whatever needs the applicant's attention floats to the top; within a
+ // group, the most recently touched application comes first.
+ return [...filtered].sort((a, b) => {
+ const aNeeds = APPLICANT_ACTION_STATUSES.includes(a.status) ? 0 : 1;
+ const bNeeds = APPLICANT_ACTION_STATUSES.includes(b.status) ? 0 : 1;
+ if (aNeeds !== bNeeds) return aNeeds - bNeeds;
+ const aAt = a.submittedAt ?? a.createdAt;
+ const bAt = b.submittedAt ?? b.createdAt;
+ return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
+ });
+ }, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
- const {
- setPageIndex: setLicencePage,
- pageSize: licencePageSize,
- setPageSize: setLicencePageSize,
- paginate: paginateLicences,
- } = useServerTable({ pageSize: 10 });
- const licencePage = paginateLicences(licenceItems);
+ const pageCount = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
+ const clampedPage = Math.min(pageIndex, pageCount - 1);
+ const pageItems = items.slice(clampedPage * PAGE_SIZE, clampedPage * PAGE_SIZE + PAGE_SIZE);
- const licenceColumns: AdvancedColumn[] = [
- {
- header: 'Certificate',
- cell: ({ row }) => (
-
- {row.original.certificateNumber}
-
- ),
- },
- {
- header: 'Licence',
- cell: ({ row }) => {localized(row.original.licenseType?.name) || '—'},
- },
- {
- header: 'Valid until',
- cell: ({ row }) => (
-
- {new Date(row.original.expiryDate).toLocaleDateString()}
-
- ),
- },
- {
- header: 'Status',
- cell: ({ row }) => (
-
- {row.original.status}
-
- ),
- },
- {
- header: '',
- label: 'Actions',
- align: 'right',
- cell: ({ row }) => (
- }
- onClick={() => downloadCertificate(row.original.id)}
- >
- Certificate
-
- ),
- },
- ];
+ function clearFilters() {
+ setSearch('');
+ setStatusFilter(null);
+ setDateFrom('');
+ setDateTo('');
+ setBucketFilter(null);
+ setPageIndex(0);
+ }
- const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
- const page = paginate(items);
+ function toggleBucket(bucket: Exclude) {
+ setBucketFilter((current) => (current === bucket ? null : bucket));
+ setPageIndex(0);
+ setTab('applications');
+ }
- const applicationColumns: AdvancedColumn[] = [
- {
- header: 'Number',
- cell: ({ row }) => (
-
- {row.original.applicationNumber}
-
- ),
- },
- {
- header: 'Company',
- cell: ({ row }) => {applicantOrCompanyName(row.original) ?? '—'},
- },
- {
- header: 'Status',
- cell: ({ row }) => (
-
- {STATUS_LABELS[row.original.status]}
-
- ),
- },
- {
- header: 'Submitted',
- cell: ({ row }) => (
-
- {row.original.submittedAt
- ? new Date(row.original.submittedAt).toLocaleDateString()
- : '—'}
-
- ),
- },
- {
- header: '',
- label: 'Actions',
- align: 'right',
- cell: ({ row }) => {
- const app = row.original;
- return (
-
- {capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
-
- )}
- {/* An issued application's primary action is the certificate. It
- used to be "View", which opened the application wizard — so
- the one thing the applicant came back for was the one thing
- the button did not do. */}
- {app.status === 'CERTIFICATE_ISSUED' && (
- }
- onClick={() => openCertificateForApplication(app.id)}
- >
- Certificate
-
- )}
-
-
- );
- },
- },
- ];
+ const statusLabel = (status: LicenseStatus) =>
+ t(`applications.status.${status}`, { defaultValue: status });
+
+ const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
return (
-
-
- Licence applications
-
-
- Your licences and applications, and the catalogue to file a new one.
-
-
- {(licences?.items ?? []).length > 0 && (
- <>
-
- My licences
-
-
-
-
- >
- )}
-
-
- My applications
-
-
- {items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
-
-
- Your payment has been received. The certificate is being prepared
- and will appear under My licences above once it is issued.
-
-
- )}
-
- {allItems.length > 0 && (
-
-
- }
- value={search}
- onChange={(e) => setSearch(e.currentTarget.value)}
- w={220}
- />
-
-
- )}
-
- {items.length === 0 ? (
-
-
-
- {hasFilters
- ? 'No applications match these filters.'
- : 'You have not filed any applications yet.'}
+
+
+
+
+ {t('applications.title')}
+
+ {t('applications.subtitle')}
-
- {hasFilters ? 'Try widening or clearing the filters.' : 'Pick a licence below to get started.'}
-
-
-
- ) : (
-
-
+ } onClick={() => changeTab('apply')}>
+ {t('applications.newApplication')}
+
+
+
+
+ toggleBucket('needsYou')}
/>
-
- )}
+ toggleBucket('inProgress')}
+ />
+ toggleBucket('completed')}
+ />
+ changeTab('licences')}
+ />
+
- {/* Last, for the same reason as on the dashboard: someone opening this
- page came to check on what they already filed, not to browse. */}
-
- Apply for a licence
-
-
+ changeTab((v as Tab) ?? 'applications')}
+ variant="pills"
+ classNames={{ list: classes.list, tab: classes.tab }}
+ >
+
+ {t('applications.tabs.applications')}
+ {t('applications.tabs.licences')}
+ {t('applications.tabs.apply')}
+
+
+
+ {tab === 'applications' && (
+
+ {items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
+
+ {t('applications.notice.paymentReceived')}
+
+ )}
+
+ {allItems.length > 0 && (
+
+
+ }
+ value={search}
+ onChange={(e) => {
+ setSearch(e.currentTarget.value);
+ setPageIndex(0);
+ }}
+ w={220}
+ />
+
+
+ )}
+
+ {isFetching ? (
+
+
+
+
+
+ ) : items.length === 0 ? (
+ changeTab('apply') }
+ }
+ />
+ ) : (
+ <>
+
+ {pageItems.map((app) => (
+ handleBypass(app.id)}
+ onCertificate={() => openCertificateForApplication(app.id)}
+ onPay={() => pay(app.id)}
+ onNavigate={() =>
+ navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`)
+ }
+ />
+ ))}
+
+ {pageCount > 1 && (
+
+ setPageIndex(p - 1)}
+ />
+
+ )}
+ >
+ )}
+
+ )}
+
+ {tab === 'licences' && (
+
+ {expiringSoon.length > 0 && (
+ }
+ title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
+ >
+
+ {expiringSoon
+ .map((l) => `${l.certificateNumber} — ${l.daysUntilExpiry ?? daysUntil(l.expiryDate)}d`)
+ .join(' · ')}
+
+
+ )}
+ {isFetchingLicences ? (
+
+
+
+
+
+ ) : licenceItems.length === 0 ? (
+
+ ) : (
+
+ {licenceItems.map((license) => (
+ downloadCertificate(license.id)}
+ onRenew={() => renewLicense(license)}
+ />
+ ))}
+
+ )}
+
+ )}
+
+ {tab === 'apply' && }
+
);
}
+// --------------------------------------------------------------- components
+
+function StatTile({
+ label,
+ value,
+ icon: Icon,
+ color,
+ active,
+ onClick,
+}: {
+ label: string;
+ value: number;
+ icon: typeof IconAlertTriangle;
+ color: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+
+
+
+ );
+}
+
+function ApplicationCard({
+ app,
+ language,
+ statusLabel,
+ bypassEnabled,
+ bypassing,
+ isPaying,
+ onBypass,
+ onCertificate,
+ onPay,
+ onNavigate,
+}: {
+ app: LicenseApplication;
+ language: string;
+ statusLabel: (status: LicenseStatus) => string;
+ bypassEnabled: boolean;
+ bypassing: boolean;
+ isPaying: boolean;
+ onBypass: () => void;
+ onCertificate: () => void;
+ onPay: () => void;
+ onNavigate: () => void;
+}) {
+ const { t } = useTranslation();
+ const status = app.status;
+ const Icon =
+ status === 'PAYMENT_PENDING'
+ ? IconCreditCard
+ : status === 'RESUBMIT_REQUIRED'
+ ? IconAlertTriangle
+ : IconFileText;
+
+ return (
+
+
+
+
+
+
+
+
+ {localized(app.licenseType?.name, language) || 'Licence application'}
+
+
+ {app.applicationNumber}
+ {applicantOrCompanyName(app) ? ` · ${applicantOrCompanyName(app)}` : ''}
+
+
+
+
+ {statusLabel(status)}
+
+
+
+
+
+
+
+ {app.submittedAt
+ ? t('applications.card.submitted', { date: new Date(app.submittedAt).toLocaleDateString() })
+ : t('applications.card.notFiled')}
+ {status === 'PAYMENT_PENDING' &&
+ ` · ${t('applications.card.feeDue')}: ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`}
+
+
+ {bypassEnabled && status === 'PAYMENT_PENDING' && (
+
+ )}
+ {/* An issued application's primary action is the certificate. It
+ used to be "View", which opened the application wizard — so the
+ one thing the applicant came back for was the one thing the
+ button did not do. */}
+ {status === 'CERTIFICATE_ISSUED' && (
+ } onClick={onCertificate}>
+ {t('applications.actions.certificate')}
+
+ )}
+
+
+
+
+ );
+}
+
export default MyApplicationsPage;
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts
index fb540d064..46cfb5e5f 100644
--- a/apps/portal/src/app/i18n/locales/am.ts
+++ b/apps/portal/src/app/i18n/locales/am.ts
@@ -111,6 +111,80 @@ export const am: Translations = {
quickActions: 'ፈጣን ድርጊቶች',
},
+ applications: {
+ title: 'የፍቃድ ማመልከቻዎች',
+ subtitle: 'ለ EMA ያስገቡትን ሁሉ ይከታተሉ።',
+ newApplication: 'አዲስ ማመልከቻ',
+ tabs: {
+ applications: 'ማመልከቻዎች',
+ licences: 'ፍቃዶች',
+ apply: 'አመልክት',
+ },
+ stats: {
+ needsYou: 'እርምጃ ይፈልጋል',
+ inProgress: 'በሂደት ላይ',
+ completed: 'የተጠናቀቀ',
+ activeLicences: 'ንቁ ፍቃዶች',
+ },
+ filters: {
+ search: 'ፈልግ',
+ searchPlaceholder: 'ቁጥር ወይም አመልካች',
+ status: 'ሁኔታ',
+ any: 'ማንኛውም',
+ from: 'ከ',
+ to: 'እስከ',
+ clear: 'አጽዳ',
+ },
+ empty: {
+ noneTitle: 'እስካሁን ምንም ማመልከቻ አላስገቡም',
+ noneBody: 'ለመጀመር ከ"አመልክት" ትር ፍቃድ ይምረጡ።',
+ noMatchTitle: 'ከዚህ ማጣሪያ ጋር የሚዛመድ ማመልከቻ የለም',
+ noMatchBody: 'ማጣሪያውን ያስፉ ወይም ያጽዱ።',
+ clearFilters: 'ማጣሪያ አጽዳ',
+ browse: 'ፍቃዶችን ይመልከቱ',
+ },
+ card: {
+ submitted: 'የገባው {{date}}',
+ notFiled: 'ገና አልገባም',
+ feeDue: 'የሚከፈል ክፍያ',
+ },
+ actions: {
+ continue: 'ቀጥል',
+ fixResubmit: 'አስተካክለህ እንደገና አስገባ',
+ pay: '{{amount}} {{currency}} ክፈል',
+ certificate: 'የምስክር ወረቀት',
+ view: 'ይመልከቱ',
+ bypass: 'ክፍያ ዝለል',
+ renew: 'አድስ',
+ },
+ notice: {
+ paymentReceived:
+ 'ክፍያዎ ደርሷል። የምስክር ወረቀቱ በመዘጋጀት ላይ ሲሆን ከተሰጠ በኋላ ከ"ፍቃዶች" ስር ይታያል።',
+ expiringSoon_one: 'አንድ ፍቃድ በቅርቡ ያበቃል',
+ expiringSoon_other: '{{count}} ፍቃዶች በቅርቡ ያበቃሉ',
+ },
+ licences: {
+ empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
+ },
+ status: {
+ DRAFT: 'ረቂቅ',
+ SUBMITTED: 'ገብቷል',
+ UNDER_REVIEW: 'በግምገማ ላይ',
+ UNDER_EVALUATION: 'በምዘና ላይ',
+ RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
+ INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
+ INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
+ APPROVED: 'ጸድቋል',
+ REJECTED: 'ውድቅ ተደርጓል',
+ ON_HOLD: 'ላይ ቆሟል',
+ PAYMENT_PENDING: 'ክፍያ በመጠባበቅ ላይ',
+ PAID: 'ተከፍሏል',
+ PAYMENT_CONFIRMED: 'ምስክር ወረቀት በዝግጅት ላይ',
+ CERTIFICATE_ISSUED: 'ምስክር ወረቀት ተሰጥቷል',
+ COMPLETED: 'ተጠናቋል',
+ },
+ },
+
/** Field labels shared by the completeness nudge and the requirement gates. */
profileFields: {
firstName: 'የመጀመሪያ ስም',
diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts
index adae0568b..55f22dc27 100644
--- a/apps/portal/src/app/i18n/locales/en.ts
+++ b/apps/portal/src/app/i18n/locales/en.ts
@@ -109,6 +109,80 @@ export const en = {
quickActions: 'Quick actions',
},
+ applications: {
+ title: 'Licence applications',
+ subtitle: 'Track everything you have filed with EMA.',
+ newApplication: 'New application',
+ tabs: {
+ applications: 'Applications',
+ licences: 'Licences',
+ apply: 'Apply',
+ },
+ stats: {
+ needsYou: 'Needs your action',
+ inProgress: 'In progress',
+ completed: 'Completed',
+ activeLicences: 'Active licences',
+ },
+ filters: {
+ search: 'Search',
+ searchPlaceholder: 'Number or applicant',
+ status: 'Status',
+ any: 'Any',
+ from: 'From',
+ to: 'To',
+ clear: 'Clear',
+ },
+ empty: {
+ noneTitle: 'You have not filed any applications yet',
+ noneBody: 'Pick a licence from the Apply tab to get started.',
+ noMatchTitle: 'No applications match these filters',
+ noMatchBody: 'Try widening or clearing the filters.',
+ clearFilters: 'Clear filters',
+ browse: 'Browse licences',
+ },
+ card: {
+ submitted: 'Submitted {{date}}',
+ notFiled: 'Not filed yet',
+ feeDue: 'Fee due',
+ },
+ actions: {
+ continue: 'Continue',
+ fixResubmit: 'Fix & resubmit',
+ pay: 'Pay {{amount}} {{currency}}',
+ certificate: 'Certificate',
+ view: 'View',
+ bypass: 'Bypass payment',
+ renew: 'Renew',
+ },
+ notice: {
+ paymentReceived:
+ 'Your payment has been received. The certificate is being prepared and will appear under Licences once it is issued.',
+ expiringSoon_one: 'A licence is expiring soon',
+ expiringSoon_other: '{{count}} licences are expiring soon',
+ },
+ licences: {
+ empty: 'No licence has been issued to you yet. One appears here once an application is approved and paid.',
+ },
+ status: {
+ DRAFT: 'Draft',
+ SUBMITTED: 'Submitted',
+ UNDER_REVIEW: 'Under Review',
+ UNDER_EVALUATION: 'Under Evaluation',
+ RESUBMIT_REQUIRED: 'Resubmit Required',
+ INSPECTION_PENDING: 'Inspection Pending',
+ INSPECTION_COMPLETED: 'Inspection Completed',
+ APPROVED: 'Approved',
+ REJECTED: 'Rejected',
+ ON_HOLD: 'On Hold',
+ PAYMENT_PENDING: 'Payment Pending',
+ PAID: 'Paid',
+ PAYMENT_CONFIRMED: 'Preparing Certificate',
+ CERTIFICATE_ISSUED: 'Certificate Issued',
+ COMPLETED: 'Completed',
+ },
+ },
+
/** Field labels shared by the completeness nudge and the requirement gates. */
profileFields: {
firstName: 'First name',