mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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,
|
Card,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Pagination,
|
|
||||||
Paper,
|
Paper,
|
||||||
Progress,
|
Progress,
|
||||||
Select,
|
Select,
|
||||||
@@ -27,14 +26,13 @@ import {
|
|||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconCreditCard,
|
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} 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 { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||||
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
|
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
|
||||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||||
@@ -57,8 +55,6 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import classes from './MyApplicationsPage.module.css';
|
import classes from './MyApplicationsPage.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 8;
|
|
||||||
|
|
||||||
/** Days before expiry at which a licence is worth flagging. */
|
/** Days before expiry at which a licence is worth flagging. */
|
||||||
const EXPIRY_WARNING_DAYS = 60;
|
const EXPIRY_WARNING_DAYS = 60;
|
||||||
|
|
||||||
@@ -94,7 +90,7 @@ function tabFromHash(hash: string): Tab {
|
|||||||
export function MyApplicationsPage() {
|
export function MyApplicationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data, isFetching } = useGetMyApplicationsQuery();
|
const { data, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||||
const { pay, isPaying } = useApplicationPayment();
|
const { pay, isPaying } = useApplicationPayment();
|
||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
@@ -181,8 +177,8 @@ export function MyApplicationsPage() {
|
|||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
|
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
|
||||||
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const result = { needsYou: 0, inProgress: 0, completed: 0 };
|
const result = { needsYou: 0, inProgress: 0, completed: 0 };
|
||||||
@@ -218,9 +214,7 @@ export function MyApplicationsPage() {
|
|||||||
});
|
});
|
||||||
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
|
const page = paginate(items);
|
||||||
const clampedPage = Math.min(pageIndex, pageCount - 1);
|
|
||||||
const pageItems = items.slice(clampedPage * PAGE_SIZE, clampedPage * PAGE_SIZE + PAGE_SIZE);
|
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
@@ -242,6 +236,126 @@ export function MyApplicationsPage() {
|
|||||||
|
|
||||||
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
|
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 (
|
return (
|
||||||
<Container size="lg" py="lg">
|
<Container size="lg" py="lg">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -368,13 +482,7 @@ export function MyApplicationsPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFetching ? (
|
{!isFetching && items.length === 0 ? (
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={112} radius="md" />
|
|
||||||
<Skeleton height={112} radius="md" />
|
|
||||||
<Skeleton height={112} radius="md" />
|
|
||||||
</Stack>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconFileText}
|
icon={IconFileText}
|
||||||
title={hasFilters ? t('applications.empty.noMatchTitle') : t('applications.empty.noneTitle')}
|
title={hasFilters ? t('applications.empty.noMatchTitle') : t('applications.empty.noneTitle')}
|
||||||
@@ -386,36 +494,18 @@ export function MyApplicationsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<AdvancedTable
|
||||||
<Stack gap="sm">
|
columns={applicationColumns}
|
||||||
{pageItems.map((app) => (
|
data={page.rows}
|
||||||
<ApplicationCard
|
tableName={t('applications.tabs.applications')}
|
||||||
key={app.id}
|
itemCount={page.itemCount}
|
||||||
app={app}
|
pageIndex={page.pageIndex}
|
||||||
language={i18n.language}
|
onPageChange={setPageIndex}
|
||||||
statusLabel={statusLabel}
|
pageSize={pageSize}
|
||||||
bypassEnabled={Boolean(capabilities?.bypassEnabled)}
|
onPageSizeChange={setPageSize}
|
||||||
bypassing={bypassing}
|
refresh={refetch}
|
||||||
isPaying={isPaying}
|
isLoading={isFetching}
|
||||||
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>
|
</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;
|
export default MyApplicationsPage;
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export const am: Translations = {
|
|||||||
notFiled: 'ገና አልገባም',
|
notFiled: 'ገና አልገባም',
|
||||||
feeDue: 'የሚከፈል ክፍያ',
|
feeDue: 'የሚከፈል ክፍያ',
|
||||||
},
|
},
|
||||||
|
table: {
|
||||||
|
licence: 'ፍቃድ',
|
||||||
|
applicant: 'አመልካች',
|
||||||
|
progress: 'ደረጃ',
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'ቀጥል',
|
continue: 'ቀጥል',
|
||||||
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ export const en = {
|
|||||||
notFiled: 'Not filed yet',
|
notFiled: 'Not filed yet',
|
||||||
feeDue: 'Fee due',
|
feeDue: 'Fee due',
|
||||||
},
|
},
|
||||||
|
table: {
|
||||||
|
licence: 'Licence',
|
||||||
|
applicant: 'Applicant',
|
||||||
|
progress: 'Progress',
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
continue: 'Continue',
|
continue: 'Continue',
|
||||||
fixResubmit: 'Fix & resubmit',
|
fixResubmit: 'Fix & resubmit',
|
||||||
|
|||||||
Reference in New Issue
Block a user