feat: enhance MyApplicationsPage with segmented tabs and improved styling

- Added a new CSS module for MyApplicationsPage to style the segmented tab bar and clickable tiles.
- Implemented tab navigation for applications, licences, and apply sections, improving user experience.
- Introduced filtering options for applications, including search, status, and date range.
- Enhanced the display of application and licence information with new components and improved layout.
- Updated translations for the new features in both Amharic and English.
This commit is contained in:
estifanos
2026-08-12 07:37:53 +00:00
parent 362e6d88b4
commit 5aea4b1c9f
6 changed files with 855 additions and 434 deletions

View File

@@ -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 (
<Center h={400}>
@@ -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)' }}
>
<Group gap="xs" mb="sm">
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
@@ -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 (
<Card withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{localized(license.licenseType?.name) || 'Licence'}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber}
</Text>
</Box>
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
>
{expired ? 'Expired' : license.status}
</Badge>
</Group>
<Divider my="sm" />
<Group justify="space-between" align="center">
<Box>
<Text size="xs" c="dimmed">
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}>
{formatDate(license.expiryDate)}
</Text>
</Box>
<Tooltip label="Download certificate">
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</Group>
{/* 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 && (
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
)}
</Card>
);
}
/**
* 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

View File

@@ -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 (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{localized(license.licenseType?.name) || 'Licence'}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber}
</Text>
</Box>
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
>
{expired ? 'Expired' : license.status}
</Badge>
</Group>
<Divider my="sm" />
<Group justify="space-between" align="center">
<Box>
<Text size="xs" c="dimmed">
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}>
{formatDate(license.expiryDate)}
</Text>
</Box>
<Tooltip label="Download certificate">
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</Group>
{/* 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 && (
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? 'Renew — this licence has expired'
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
</Button>
)}
</Card>
);
}
export default LicenseCard;

View File

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

View File

@@ -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<Bucket, null> {
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<Tab>(() =>
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<LicenseStatus | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo);
const [bucketFilter, setBucketFilter] = useState<Bucket>(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<IssuedLicense>[] = [
{
header: 'Certificate',
cell: ({ row }) => (
<Text size="sm" fw={500} ff="monospace">
{row.original.certificateNumber}
</Text>
),
},
{
header: 'Licence',
cell: ({ row }) => <Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>,
},
{
header: 'Valid until',
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{new Date(row.original.expiryDate).toLocaleDateString()}
</Text>
),
},
{
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={row.original.status === 'ACTIVE' ? 'green' : 'gray'}>
{row.original.status}
</Badge>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => (
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={14} />}
onClick={() => downloadCertificate(row.original.id)}
>
Certificate
</Button>
),
},
];
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<Bucket, null>) {
setBucketFilter((current) => (current === bucket ? null : bucket));
setPageIndex(0);
setTab('applications');
}
const applicationColumns: AdvancedColumn<LicenseApplication>[] = [
{
header: 'Number',
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.applicationNumber}
</Text>
),
},
{
header: 'Company',
cell: ({ row }) => <Text size="sm">{applicantOrCompanyName(row.original) ?? '—'}</Text>,
},
{
header: 'Status',
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{STATUS_LABELS[row.original.status]}
</Badge>
),
},
{
header: 'Submitted',
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? new Date(row.original.submittedAt).toLocaleDateString()
: '—'}
</Text>
),
},
{
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const app = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={() => handleBypass(app.id)}
title="Testing only — marks the fee paid and issues the licence"
>
Bypass payment
</Button>
)}
{/* 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' && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => openCertificateForApplication(app.id)}
>
Certificate
</Button>
)}
<Button
size="xs"
loading={isPaying && app.status === 'PAYMENT_PENDING'}
variant={
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING'
? 'filled'
: 'subtle'
}
color={
app.status === 'RESUBMIT_REQUIRED'
? 'orange'
: app.status === 'PAYMENT_PENDING'
? 'yellow'
: undefined
}
onClick={() =>
// Paying leaves the SPA for Telebirr, so this is a provider
// hand-off rather than a route change.
app.status === 'PAYMENT_PENDING'
? pay(app.id)
: navigate(
`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT'
? 'Continue'
: app.status === 'RESUBMIT_REQUIRED'
? 'Fix & resubmit'
: app.status === 'PAYMENT_PENDING'
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
: app.status === 'CERTIFICATE_ISSUED'
? 'Application'
: 'View'}
</Button>
</Group>
);
},
},
];
const statusLabel = (status: LicenseStatus) =>
t(`applications.status.${status}`, { defaultValue: status });
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
return (
<Container size="lg" py="md">
<Title order={3} mb="xs">
Licence applications
</Title>
<Text size="sm" c="dimmed" mb="md">
Your licences and applications, and the catalogue to file a new one.
</Text>
{(licences?.items ?? []).length > 0 && (
<>
<Title order={4} mb="sm">
My licences
</Title>
<Card withBorder padding={0} mb="xl">
<AdvancedTable
columns={licenceColumns}
data={licencePage.rows}
tableName="My licences"
itemCount={licencePage.itemCount}
pageIndex={licencePage.pageIndex}
onPageChange={setLicencePage}
pageSize={licencePageSize}
onPageSizeChange={setLicencePageSize}
refresh={refetchLicences}
isLoading={isFetchingLicences}
/>
</Card>
</>
)}
<Title order={4} mb="sm">
My applications
</Title>
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
<Card withBorder padding="sm" radius="md" mb="sm"
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
<Text size="sm">
Your payment has been received. The certificate is being prepared
and will appear under My licences above once it is issued.
</Text>
</Card>
)}
{allItems.length > 0 && (
<Paper withBorder p="sm" mb="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label="Search"
placeholder="Number or company"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={220}
/>
<Select
label="Status"
placeholder="Any"
data={ALL_APPLICATION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={statusFilter}
onChange={(v) => setStatusFilter(v as LicenseStatus | null)}
clearable
w={200}
/>
<AmharicDatePicker
label="From"
value={dateFrom}
onChange={setDateFrom}
dateFormat="date"
w={160}
/>
<AmharicDatePicker
label="To"
value={dateTo}
onChange={setDateTo}
dateFormat="date"
w={160}
/>
{hasFilters && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={() => {
setSearch('');
setStatusFilter(null);
setDateFrom('');
setDateTo('');
}}
>
Clear
</Button>
)}
</Group>
</Paper>
)}
{items.length === 0 ? (
<Card withBorder padding="xl">
<Stack align="center" gap="xs">
<Text c="dimmed">
{hasFilters
? 'No applications match these filters.'
: 'You have not filed any applications yet.'}
<Container size="lg" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Box>
<Title order={2}>{t('applications.title')}</Title>
<Text c="dimmed" size="sm" mt={2}>
{t('applications.subtitle')}
</Text>
<Text size="sm" c="dimmed">
{hasFilters ? 'Try widening or clearing the filters.' : 'Pick a licence below to get started.'}
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding={0}>
<AdvancedTable
columns={applicationColumns}
data={page.rows}
tableName="My applications"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
</Box>
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
{t('applications.newApplication')}
</Button>
</Group>
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
<StatTile
label={t('applications.stats.needsYou')}
value={counts.needsYou}
icon={IconAlertTriangle}
color="orange"
active={bucketFilter === 'needsYou'}
onClick={() => toggleBucket('needsYou')}
/>
</Card>
)}
<StatTile
label={t('applications.stats.inProgress')}
value={counts.inProgress}
icon={IconClockHour4}
color="blue"
active={bucketFilter === 'inProgress'}
onClick={() => toggleBucket('inProgress')}
/>
<StatTile
label={t('applications.stats.completed')}
value={counts.completed}
icon={IconClipboardList}
color="teal"
active={bucketFilter === 'completed'}
onClick={() => toggleBucket('completed')}
/>
<StatTile
label={t('applications.stats.activeLicences')}
value={activeLicences.length}
icon={IconCertificate}
color="grape"
active={tab === 'licences'}
onClick={() => changeTab('licences')}
/>
</SimpleGrid>
{/* Last, for the same reason as on the dashboard: someone opening this
page came to check on what they already filed, not to browse. */}
<Title order={4} mt="xl" mb="sm">
Apply for a licence
</Title>
<LicenseCatalogue />
<Tabs
value={tab}
onChange={(v) => changeTab((v as Tab) ?? 'applications')}
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>
<Tabs.List>
<Tabs.Tab value="applications">{t('applications.tabs.applications')}</Tabs.Tab>
<Tabs.Tab value="licences">{t('applications.tabs.licences')}</Tabs.Tab>
<Tabs.Tab value="apply">{t('applications.tabs.apply')}</Tabs.Tab>
</Tabs.List>
</Tabs>
{tab === 'applications' && (
<Stack gap="md">
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
<Alert variant="light" color="teal" radius="md">
{t('applications.notice.paymentReceived')}
</Alert>
)}
{allItems.length > 0 && (
<Paper withBorder p="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('applications.filters.search')}
placeholder={t('applications.filters.searchPlaceholder')}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPageIndex(0);
}}
w={220}
/>
<Select
label={t('applications.filters.status')}
placeholder={t('applications.filters.any')}
data={allStatuses.map((s) => ({ value: s, label: statusLabel(s) }))}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v as LicenseStatus | null);
setPageIndex(0);
}}
clearable
w={200}
/>
<AmharicDatePicker
label={t('applications.filters.from')}
value={dateFrom}
onChange={(v) => {
setDateFrom(v);
setPageIndex(0);
}}
dateFormat="date"
w={160}
/>
<AmharicDatePicker
label={t('applications.filters.to')}
value={dateTo}
onChange={(v) => {
setDateTo(v);
setPageIndex(0);
}}
dateFormat="date"
w={160}
/>
{hasFilters && (
<Button variant="subtle" leftSection={<IconX size={14} />} onClick={clearFilters}>
{t('applications.filters.clear')}
</Button>
)}
</Group>
</Paper>
)}
{isFetching ? (
<Stack gap="sm">
<Skeleton height={112} radius="md" />
<Skeleton height={112} radius="md" />
<Skeleton height={112} radius="md" />
</Stack>
) : items.length === 0 ? (
<EmptyState
icon={IconFileText}
title={hasFilters ? t('applications.empty.noMatchTitle') : t('applications.empty.noneTitle')}
description={hasFilters ? t('applications.empty.noMatchBody') : t('applications.empty.noneBody')}
action={
hasFilters
? { label: t('applications.empty.clearFilters'), onClick: clearFilters }
: { label: t('applications.empty.browse'), onClick: () => changeTab('apply') }
}
/>
) : (
<>
<Stack gap="sm">
{pageItems.map((app) => (
<ApplicationCard
key={app.id}
app={app}
language={i18n.language}
statusLabel={statusLabel}
bypassEnabled={Boolean(capabilities?.bypassEnabled)}
bypassing={bypassing}
isPaying={isPaying}
onBypass={() => handleBypass(app.id)}
onCertificate={() => openCertificateForApplication(app.id)}
onPay={() => pay(app.id)}
onNavigate={() =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`)
}
/>
))}
</Stack>
{pageCount > 1 && (
<Group justify="center">
<Pagination
total={pageCount}
value={clampedPage + 1}
onChange={(p) => setPageIndex(p - 1)}
/>
</Group>
)}
</>
)}
</Stack>
)}
{tab === 'licences' && (
<Stack gap="md">
{expiringSoon.length > 0 && (
<Alert
variant="light"
color="orange"
radius="md"
icon={<IconClockHour4 size={18} />}
title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
>
<Text size="sm">
{expiringSoon
.map((l) => `${l.certificateNumber}${l.daysUntilExpiry ?? daysUntil(l.expiryDate)}d`)
.join(' · ')}
</Text>
</Alert>
)}
{isFetchingLicences ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Skeleton height={160} radius="md" />
<Skeleton height={160} radius="md" />
<Skeleton height={160} radius="md" />
</SimpleGrid>
) : licenceItems.length === 0 ? (
<EmptyState icon={IconCertificate} title={t('applications.licences.empty')} />
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{licenceItems.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
onDownload={() => downloadCertificate(license.id)}
onRenew={() => renewLicense(license)}
/>
))}
</SimpleGrid>
)}
</Stack>
)}
{tab === 'apply' && <LicenseCatalogue />}
</Stack>
</Container>
);
}
// --------------------------------------------------------------- components
function StatTile({
label,
value,
icon: Icon,
color,
active,
onClick,
}: {
label: string;
value: number;
icon: typeof IconAlertTriangle;
color: string;
active: boolean;
onClick: () => void;
}) {
return (
<Card
withBorder
radius="md"
padding="md"
className={`${classes.tile} ${active ? classes.tileActive : ''}`}
onClick={onClick}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fz={30} fw={700} lh={1.2} mt={4}>
{value}
</Text>
</Box>
<ThemeIcon variant="light" color={color} radius="md" size="lg">
<Icon size={18} />
</ThemeIcon>
</Group>
</Card>
);
}
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 (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" color={STATUS_COLORS[status]} radius="md">
<Icon size={16} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{localized(app.licenseType?.name, language) || 'Licence application'}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{app.applicationNumber}
{applicantOrCompanyName(app) ? ` · ${applicantOrCompanyName(app)}` : ''}
</Text>
</Box>
</Group>
<Badge color={STATUS_COLORS[status]} variant="light">
{statusLabel(status)}
</Badge>
</Group>
<Progress value={STATUS_PROGRESS[status]} color={STATUS_COLORS[status]} size="sm" radius="xl" mt="sm" />
<Group justify="space-between" align="center" mt="sm" wrap="wrap">
<Text size="xs" c="dimmed">
{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}`}
</Text>
<Group gap="xs" wrap="nowrap">
{bypassEnabled && status === 'PAYMENT_PENDING' && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={onBypass}
title="Testing only — marks the fee paid and issues the licence"
>
{t('applications.actions.bypass')}
</Button>
)}
{/* 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' && (
<Button size="xs" leftSection={<IconDownload size={14} />} onClick={onCertificate}>
{t('applications.actions.certificate')}
</Button>
)}
<Button
size="xs"
loading={isPaying && status === 'PAYMENT_PENDING'}
variant={status === 'RESUBMIT_REQUIRED' || status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'}
color={
status === 'RESUBMIT_REQUIRED' ? 'orange' : status === 'PAYMENT_PENDING' ? 'yellow' : undefined
}
// Paying leaves the SPA for Telebirr, so this is a provider
// hand-off rather than a route change.
onClick={status === 'PAYMENT_PENDING' ? onPay : onNavigate}
>
{status === 'DRAFT'
? t('applications.actions.continue')
: status === 'RESUBMIT_REQUIRED'
? t('applications.actions.fixResubmit')
: status === 'PAYMENT_PENDING'
? t('applications.actions.pay', {
amount: Number(app.feeAmount ?? 0).toLocaleString(),
currency: app.feeCurrency,
})
: status === 'CERTIFICATE_ISSUED'
? t('applications.actions.certificate')
: t('applications.actions.view')}
</Button>
</Group>
</Group>
</Card>
);
}
export default MyApplicationsPage;

View File

@@ -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: 'የመጀመሪያ ስም',

View File

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