mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: update MyApplicationsPage with AdvancedTable and add translations for table headers
This commit is contained in:
@@ -9,7 +9,6 @@ import {
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
@@ -27,14 +26,13 @@ import {
|
||||
IconCertificate,
|
||||
IconClipboardList,
|
||||
IconClockHour4,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { AmharicDatePicker, EmptyState } from '@ema-platform/ui';
|
||||
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
@@ -57,8 +55,6 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import classes from './MyApplicationsPage.module.css';
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
|
||||
@@ -94,7 +90,7 @@ function tabFromHash(hash: string): Tab {
|
||||
export function MyApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data, isFetching } = useGetMyApplicationsQuery();
|
||||
const { data, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
@@ -181,8 +177,8 @@ export function MyApplicationsPage() {
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { needsYou: 0, inProgress: 0, completed: 0 };
|
||||
@@ -218,9 +214,7 @@ export function MyApplicationsPage() {
|
||||
});
|
||||
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
||||
|
||||
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 page = paginate(items);
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
@@ -242,6 +236,126 @@ export function MyApplicationsPage() {
|
||||
|
||||
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
|
||||
|
||||
const applicationColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
header: t('applications.table.licence'),
|
||||
cell: ({ row }) => (
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, i18n.language) || '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('applications.table.applicant'),
|
||||
cell: ({ row }) => <Text size="sm">{applicantOrCompanyName(row.original) ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('common.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{statusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('applications.table.progress'),
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('common.date'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.submittedAt
|
||||
? new Date(row.original.submittedAt).toLocaleDateString()
|
||||
: t('applications.card.notFiled')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('common.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"
|
||||
>
|
||||
{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. */}
|
||||
{app.status === 'CERTIFICATE_ISSUED' && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => openCertificateForApplication(app.id)}
|
||||
>
|
||||
{t('applications.actions.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'
|
||||
? t('applications.actions.continue')
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? t('applications.actions.fixResubmit')
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? t('applications.actions.pay', {
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? t('applications.actions.certificate')
|
||||
: t('applications.actions.view')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="lg">
|
||||
<Stack gap="lg">
|
||||
@@ -368,13 +482,7 @@ export function MyApplicationsPage() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<Stack gap="sm">
|
||||
<Skeleton height={112} radius="md" />
|
||||
<Skeleton height={112} radius="md" />
|
||||
<Skeleton height={112} radius="md" />
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
{!isFetching && items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={IconFileText}
|
||||
title={hasFilters ? t('applications.empty.noMatchTitle') : t('applications.empty.noneTitle')}
|
||||
@@ -386,36 +494,18 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
<AdvancedTable
|
||||
columns={applicationColumns}
|
||||
data={page.rows}
|
||||
tableName={t('applications.tabs.applications')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -510,119 +600,5 @@ function StatTile({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -148,6 +148,11 @@ export const am: Translations = {
|
||||
notFiled: 'ገና አልገባም',
|
||||
feeDue: 'የሚከፈል ክፍያ',
|
||||
},
|
||||
table: {
|
||||
licence: 'ፍቃድ',
|
||||
applicant: 'አመልካች',
|
||||
progress: 'ደረጃ',
|
||||
},
|
||||
actions: {
|
||||
continue: 'ቀጥል',
|
||||
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
||||
|
||||
@@ -146,6 +146,11 @@ export const en = {
|
||||
notFiled: 'Not filed yet',
|
||||
feeDue: 'Fee due',
|
||||
},
|
||||
table: {
|
||||
licence: 'Licence',
|
||||
applicant: 'Applicant',
|
||||
progress: 'Progress',
|
||||
},
|
||||
actions: {
|
||||
continue: 'Continue',
|
||||
fixResubmit: 'Fix & resubmit',
|
||||
|
||||
Reference in New Issue
Block a user