feat: update MyApplicationsPage with AdvancedTable and add translations for table headers

This commit is contained in:
estifanos
2026-08-12 07:45:00 +00:00
parent 5aea4b1c9f
commit d0bbfe30d1
3 changed files with 147 additions and 161 deletions

View File

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

View File

@@ -148,6 +148,11 @@ export const am: Translations = {
notFiled: 'ገና አልገባም',
feeDue: 'የሚከፈል ክፍያ',
},
table: {
licence: 'ፍቃድ',
applicant: 'አመልካች',
progress: 'ደረጃ',
},
actions: {
continue: 'ቀጥል',
fixResubmit: 'አስተካክለህ እንደገና አስገባ',

View File

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