mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: 'የመጀመሪያ ስም',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user