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 && ( - - )} - - ); -} - /** * 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 && ( + + )} + + ); +} + +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 }) => ( - - ), - }, - ]; + 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' && ( - - )} - - - ); - }, - }, - ]; + 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} - /> - ({ value: s, label: statusLabel(s) }))} + value={statusFilter} + onChange={(v) => { + setStatusFilter(v as LicenseStatus | null); + setPageIndex(0); + }} + clearable + w={200} + /> + { + setDateFrom(v); + setPageIndex(0); + }} + dateFormat="date" + w={160} + /> + { + setDateTo(v); + setPageIndex(0); + }} + dateFormat="date" + w={160} + /> + {hasFilters && ( + + )} + + + )} + + {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' && ( + + )} + + + + + ); +} + 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',